Author SHA1 Message Date
Panfilov Konstantin ba33a76a34 feat: неблокирующий сбор User Flow и гостевых ошибок (api#376 api#377)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
2026-10-31 13:29:45 +03:00
Panfilov Konstantin 791761a782 fix: тематическое окно ошибки загрузки (#33)
deploy-preland / deploy zoomstage.online (push) Successful in 1m7s
2026-10-28 09:07:00 +03:00
Panfilov Konstantin 4a6683f335 test: учитывать текущие UTM defaults владельца в stage CI #347
deploy-preland / deploy zoomstage.online (push) Successful in 1m0s
2026-10-28 05:34:19 +03:00
Panfilov Konstantin 2970f990e0 docs: сохранить фактический stage config без изменения runtime #347
deploy-preland / deploy zoomstage.online (push) Failing after 35s
2026-10-28 05:15:31 +03:00
Panfilov Konstantin 875549a5af fix(admin): уточнить ошибку допустимого значения part #347 2026-10-28 05:11:28 +03:00
Panfilov Konstantin 01f83375a7 docs: уточнить допустимые варианты part #347 2026-10-28 05:10:43 +03:00
Panfilov Konstantin 469bec1595 api#347: обновить полный перечень девяти UTM 2026-10-28 05:06:53 +03:00
Panfilov Konstantin f7d1792e23 api#347: сохранить part в именованных наборах и процентных контрактах 2026-10-28 05:06:11 +03:00
Panfilov Konstantin 1993f7b4b1 api#347: проверить старые recovery snapshots с default2 2026-10-28 05:04:08 +03:00
Panfilov Konstantin 0d350ed14b api#347: передавать и настраивать UTM part без ссылки в Preland 2026-10-28 05:04:08 +03:00
Panfilov Konstantin 94619432a3 feat(admin): независимые именованные UTM-наборы 2026-10-26 18:38:11 +03:00
Panfilov Konstantin 941a4f01aa feat(admin): ручные варианты UTM и независимый редактор config (#30) 2026-10-26 05:42:03 +03:00
Panfilov Konstantin 47405b27d8 feat(admin): единые процентные веса без старого формата (#28) 2026-10-26 04:56:32 +03:00
Panfilov Konstantin a22519e754 fix(admin): номера UTM и дата чтения с хостинга (#29) 2026-10-26 04:42:40 +03:00
Panfilov Konstantin 00fbef9ede feat(admin): скрытие доменов и сохраняемый порядок колонок (#26) 2026-10-26 04:11:44 +03:00
Panfilov Konstantin dd6d221dd7 feat(admin): edit domain settings inline and copy saved configurations
deploy-preland / deploy zoomstage.online (push) Successful in 57s
2026-10-26 03:06:11 +03:00
Panfilov Konstantin 84f70b1c71 fix(admin): align UTM notes and block obsolete chb publication
deploy-preland / deploy zoomstage.online (push) Successful in 54s
2026-10-25 23:16:35 +03:00
Panfilov Konstantin f2097d4800 feat(admin): compact UTM percentages and shared notes
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
2026-10-25 22:59:37 +03:00
Panfilov Konstantin 4fc4fd8162 fix(admin): route multi-megabyte sets to SFTP before shell escaping
deploy-preland / deploy zoomstage.online (push) Successful in 1m3s
2026-10-25 22:14:15 +03:00
Panfilov Konstantin 6ec813bc99 fix(admin): refresh verified connection state without losing drafts
deploy-preland / deploy zoomstage.online (push) Successful in 56s
2026-10-25 19:27:09 +03:00
Panfilov Konstantin 522aaf8554 feat(admin): remember login for 90 days and support REG SSH limits
deploy-preland / deploy zoomstage.online (push) Successful in 57s
2026-10-25 19:17:51 +03:00
Panfilov Konstantin d04b40519e fix(admin): persist private sessions across container replacement
deploy-preland / deploy zoomstage.online (push) Successful in 58s
2026-10-25 18:26:41 +03:00
Panfilov Konstantin 2cfa2e2d2e feat(admin): add runtime overview and independent saved UTM link builder
deploy-preland / deploy zoomstage.online (push) Successful in 57s
2026-10-25 18:17:23 +03:00
Panfilov Konstantin 48c71f3300 fix(admin): show verified catalog groups in UTM forms
deploy-preland / deploy zoomstage.online (push) Successful in 1m3s
2026-10-25 11:53:20 +03:00
Panfilov Konstantin 9515c1c62b feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
2026-10-25 11:33:56 +03:00
Panfilov Konstantin 5b6b2d2053 docs: план выполнения админки прелендингов (#22) 2026-10-25 10:46:16 +03:00
Panfilov Konstantin b3b14ec193 docs: проект единой админки конфигов и indpic (#22) 2026-10-25 10:35:35 +03:00
Panfilov Konstantin 847c642ca6 preland#21: закрепить три боевых .ru и отключение остальных .online 2026-10-25 09:24:40 +03:00
Panfilov Konstantin a1aa25671f preland#21: готовые доменные SVG Zoomika, Zoomila и ZoomPixel 2026-10-25 09:09:18 +03:00
Panfilov Konstantin 965eb2e6a5 fix(utm): заменить публичную chob на banch (#327)
deploy-preland / deploy zoomstage.online (push) Successful in 42s
2026-10-25 05:09:55 +03:00
Panfilov Konstantin 08febba368 api#326 Сохранить прежний random и чужие JSON токены
deploy-preland / deploy zoomstage.online (push) Successful in 45s
2026-10-25 02:10:45 +03:00
Panfilov Konstantin 81e189ca67 api#326 Числовые UTM и новый шум каждого перехода 2026-10-25 02:05:48 +03:00
Panfilov Konstantin f30742c2fb preland#18 #19: popup ошибки загрузки и однострочный ИИ-заголовок
deploy-preland / deploy zoomstage.online (push) Successful in 55s
2026-10-24 10:59:20 +03:00
Panfilov Konstantin f4ce5afdc4 docs: snapshot verified stage consent runtime config (#17)
deploy-preland / deploy zoomstage.online (push) Successful in 43s
2026-10-24 05:05:45 +03:00
Panfilov Konstantin 89cc16eb4a feat: replace chb with consent groups and preserve runtime config (api#317, #17)
deploy-preland / deploy zoomstage.online (push) Successful in 42s
2026-10-24 04:35:44 +03:00
Panfilov Konstantin 188b78a115 fix: desktop-кнопка «Выберите файл» (api#315)
deploy-preland / deploy zoomstage.online (push) Successful in 41s
2026-10-23 23:36:34 +03:00
Panfilov Konstantin f87bef2443 fix: резервные форматы примеров и stage snapshot (#16)
deploy-preland / deploy zoomstage.online (push) Successful in 42s
2026-10-23 20:10:51 +03:00
Panfilov Konstantin 0e6611da73 fix(ui): ограничить fallback высоты первым экраном #16 2026-10-23 19:46:41 +03:00
Panfilov Konstantin 198ba573f9 fix(ui): согласовать первый экран и pending #16 2026-10-23 19:40:47 +03:00
Panfilov Konstantin 01933546a7 fix(theme): mirror bounded cookie propagation reconciliation
deploy-preland / deploy zoomstage.online (push) Successful in 39s
2026-10-21 22:05:02 +03:00
Panfilov Konstantin ad8c8eb849 build(theme): сохранять LF общих partial при export (#13) 2026-10-21 20:55:28 +03:00
Panfilov Konstantin 5ffb0f37e9 fix(theme): зеркалить canonical cookie при первом boot (#13) 2026-10-21 20:50:25 +03:00
Panfilov Konstantin c6b2b227c0 fix(theme): сохранять текущий выбор без storage (#13) 2026-10-21 20:46:23 +03:00
Panfilov Konstantin 9c06977e03 feat(theme): локальный выбор и показанная тема handoff (#13) 2026-10-21 20:42:28 +03:00
Panfilov Konstantin 3740839321 fix: совпадение видимого логотипа Preland и Tour (refs #12)
deploy-preland / deploy zoomstage.online (push) Successful in 40s
2026-10-21 17:35:07 +03:00
Panfilov Konstantin ec087ea636 feat: готовый бренд ZoomStage и минимальный доменный подвал (refs #12)
deploy-preland / deploy zoomstage.online (push) Successful in 44s
2026-10-21 17:24:14 +03:00
Panfilov Konstantin a2c3197a16 merge: сохранить историю локального Android-коммита без изменения проверенного дерева (refs #11)
deploy-preland / deploy zoomstage.online (push) Successful in 45s
2026-10-21 02:30:12 +03:00
Panfilov Konstantin 57196e32a9 docs: уточнить канонический SVG-контракт (refs #11) 2026-10-21 02:30:12 +03:00
Panfilov Konstantin f2ad7ecb4c merge: синхронизировать main с проверенным staging без старого кода (refs #11)
deploy-preland / deploy zoomstage.online (push) Successful in 45s
2026-10-21 02:27:08 +03:00
Panfilov Konstantin e9a2a0d568 fix: актуальные ресурсы Zoomzilla и проверяемый выпуск (refs #11) 2026-10-21 02:27:08 +03:00
Panfilov Konstantin 144dda6d88 fix: стабильная область кнопки при смене шрифта (api#301)
deploy-preland / deploy zoomstage.online (push) Successful in 34s
2026-10-20 20:11:13 +03:00
Panfilov Konstantin 0c9a406d68 fix: единая загрузка примеров и стабильный первый кадр (api#301)
deploy-preland / deploy zoomstage.online (push) Successful in 33s
2026-10-20 19:52:30 +03:00
Panfilov Konstantin 75ccf14889 Показывать ошибку после восстановления истории (#9)
deploy-preland / deploy zoomstage.online (push) Successful in 36s
2026-10-20 18:52:35 +03:00
Panfilov Konstantin 6a2532cb87 Обработать browser storage ошибки и показывать восстановленную форму (#9) 2026-10-20 18:43:25 +03:00
Panfilov Konstantin 4c5d3de7bc Merge remote-tracking branch 'origin/staging' into fix/issue299-resume 2026-10-20 18:33:12 +03:00
Panfilov Konstantin 5afa71ec84 Изолировать callbacks отменённой загрузки (#9) 2026-10-20 18:33:12 +03:00
Panfilov Konstantin d70d396670 Восстанавливать загрузку30мин после Reload и удерживать Back (#9) 2026-10-20 18:31:43 +03:00
Panfilov Konstantin 8274946610 chore(ci): отключить production и legacy автодеплой (refs #4) 2026-10-19 14:57:39 +03:00
Panfilov Konstantin 029cd16d46 feat: компактный Index и крупные миниатюры (refs anton/api#283)
deploy-preland / deploy barbariskatest (push) Failing after 1m36s
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.online (push) Successful in 38s
deploy-preland / deploy zoompixel.ru (push) Successful in 37s
2026-10-19 14:38:31 +03:00
Panfilov Konstantin 3319499d5e fix: стабильные размеры Index при скрытии тулбаров
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.online (push) Successful in 33s
deploy-preland / deploy zoompixel.ru (push) Successful in 34s
deploy-preland / deploy barbariskatest (push) Failing after 1m47s
2026-10-19 13:48:44 +03:00
Panfilov Konstantin 953c7dd8f2 fix(upload): кликабельная мобильная кнопка
deploy-preland / deploy barbariskatest (push) Successful in 57s
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.online (push) Successful in 37s
deploy-preland / deploy zoompixel.ru (push) Successful in 34s
2026-10-19 12:26:21 +03:00
Panfilov Konstantin 21f8fbb6a4 feat(upload): фото и файлы отдельными путями на Android 2026-10-19 12:20:22 +03:00
Panfilov Konstantin 3ad7be0db3 feat(upload): фото и файлы отдельными путями на Android 2026-10-19 12:19:33 +03:00
Panfilov Konstantin 19547d1797 fix(ci): deploy only changed legacy application files
deploy-preland / deploy barbariskatest (push) Successful in 32s
deploy-preland / deploy zoompixel.online (push) Successful in 37s
deploy-preland / deploy zoompixel.ru (push) Successful in 33s
deploy-preland / deploy zoomstage.online (push) Skipped
2026-10-18 01:04:06 +03:00
Panfilov Konstantin 56456bb0cb fix(ci): tolerate existing legacy FTP directories
deploy-preland / deploy zoompixel.online (push) Successful in 35s
deploy-preland / deploy zoompixel.ru (push) Successful in 52s
deploy-preland / deploy barbariskatest (push) Failing after 1m49s
deploy-preland / deploy zoomstage.online (push) Skipped
2026-10-18 00:57:20 +03:00
Panfilov Konstantin 78b24c91f8 fix(ci): skip root directory creation on legacy FTP
deploy-preland / deploy barbariskatest (push) Failing after 30s
deploy-preland / deploy zoompixel.online (push) Successful in 28s
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.ru (push) Successful in 32s
2026-10-18 00:53:41 +03:00
Panfilov Konstantin be030f25a0 fix(ci): source legacy FTPS command file
deploy-preland / deploy barbariskatest (push) Failing after 35s
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.online (push) Successful in 31s
deploy-preland / deploy zoompixel.ru (push) Successful in 31s
2026-10-18 00:50:00 +03:00
Panfilov Konstantin 498e2df7b9 fix(ci): use FTPS client for legacy assets
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.ru (push) Successful in 29s
deploy-preland / deploy barbariskatest (push) Failing after 30s
deploy-preland / deploy zoompixel.online (push) Successful in 33s
2026-10-18 00:46:02 +03:00
Panfilov Konstantin 97fe9646b7 fix(ci): verify legacy FTP retry payloads
deploy-preland / deploy zoompixel.ru (push) Successful in 28s
deploy-preland / deploy barbariskatest (push) Failing after 44s
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.online (push) Successful in 31s
2026-10-18 00:40:02 +03:00
Panfilov Konstantin d646f98aa7 fix(ci): upload legacy files independently
deploy-preland / deploy barbariskatest (push) Failing after 45s
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.online (push) Successful in 33s
deploy-preland / deploy zoompixel.ru (push) Successful in 31s
2026-10-18 00:34:31 +03:00
Panfilov Konstantin 35b6a334ba fix(ci): clone legacy release without node checkout
deploy-preland / deploy zoompixel.online (push) Successful in 32s
deploy-preland / deploy zoompixel.ru (push) Successful in 32s
deploy-preland / deploy barbariskatest (push) Failing after 43s
deploy-preland / deploy zoomstage.online (push) Skipped
2026-10-18 00:28:31 +03:00
Panfilov Konstantin a0dee79b22 fix(ci): run legacy release on production runner
deploy-preland / deploy zoompixel.online (push) Successful in 35s
deploy-preland / deploy zoompixel.ru (push) Successful in 33s
deploy-preland / deploy barbariskatest (push) Failing after 32s
deploy-preland / deploy zoomstage.online (push) Skipped
2026-10-18 00:19:05 +03:00
Panfilov Konstantin 730042385d fix(ci): use Alpine php83 for host config
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.online (push) Successful in 34s
deploy-preland / deploy zoompixel.ru (push) Successful in 31s
deploy-preland / deploy barbariskatest (push) Canceled after 9m51s
2026-10-18 00:08:46 +03:00
Panfilov Konstantin a86978a499 chore(config): snapshot stage focus defaults
deploy-preland / deploy zoomstage.online (push) Skipped
deploy-preland / deploy zoompixel.online (push) Successful in 31s
deploy-preland / deploy zoompixel.ru (push) Successful in 37s
deploy-preland / deploy barbariskatest (push) Failing after 5m1s
2026-10-17 20:51:44 +03:00
Panfilov Konstantin ac4cc21361 fix(config): enable explicit face focus 2026-10-17 20:51:44 +03:00
Panfilov Konstantin f14ce88cc6 fix(deploy): preserve host preland config 2026-10-17 20:48:52 +03:00
Panfilov Konstantin 9835bb0ef8 feat(utm): propagate join focus mode 2026-10-17 20:46:33 +03:00
179 changed files with 8562 additions and 628 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
components/theme-client.php text eol=lf
components/theme-switch.php text eol=lf
components/theme-style.php text eol=lf
+91 -26
View File
@@ -20,12 +20,29 @@ jobs:
REMOTE_DIR: domains/zoomstage.online
steps:
- name: Install tools
run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli
run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli php-imagick
- name: Checkout staging branch
run: git clone --depth 1 --branch staging http://git.dira89kv.beget.tech:3000/anton/preland.git .
env:
RELEASE_SHA: ${{ github.sha }}
run: |
set -eu
git clone --depth 1 --branch staging http://git.dira89kv.beget.tech:3000/anton/preland.git .
git fetch --depth 1 origin "$RELEASE_SHA"
git checkout --detach "$RELEASE_SHA"
[ "$(git rev-parse HEAD)" = "$RELEASE_SHA" ]
- name: Preserve host config and add focus if absent
- name: Validate release and current stage sets before upload
run: |
set -eu
php bin/release-assets.php --check
for test in tests/check-*.php; do php "$test"; done
php bin/release-assets.php --primary
# preland#22: /N/ — runtime. Проверяем config и декодируем все 90
# обязательных изображений используемых наборов, не сверяем с Git SHA.
php bin/release-assets.php --stage-sets "$PRELAND_URL"
- name: Apply approved domain brand only if needed
env:
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }}
@@ -33,22 +50,24 @@ jobs:
SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }}
run: |
set -eu
[ -n "$SFTP_HOST" ] && [ -n "$SFTP_USER" ] && [ -n "$SFTP_PASS" ] && [ -n "$SFTP_KNOWN_HOSTS" ] || { echo "не заданы секреты ZOOMPIXEL_SFTP_*"; exit 1; }
status=0
php bin/release-brand.php --status "$PRELAND_URL" || status=$?
case "$status" in 0) exit 0 ;; 10) ;; *) exit "$status" ;; esac
# Только точная ранее согласованная пара заменяется по preland#12.
# Обычные выпуски с правильным брендом сюда не доходят.
umask 077
known_hosts=$(mktemp); batch=$(mktemp); config=$(mktemp)
trap 'rm -f "$known_hosts" "$batch" "$config"' EXIT
known_hosts=$(mktemp); batch=$(mktemp); files=$(mktemp)
trap 'rm -f "$known_hosts" "$batch" "$files"' EXIT
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch"
changed=0
php bin/ensure-focus-config.php "$config" || changed=$?
case "$changed" in
0) ;;
10) printf '%s\n' "put -p $config $REMOTE_DIR/config.json" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch" ;;
*) exit "$changed" ;;
esac
php bin/release-brand.php --files "$PRELAND_URL" > "$files"
revision=$(git rev-parse HEAD)
while read -r source target; do
# Оборванный put не должен оставлять усечённый действующий SVG.
printf '%s\n' "put -p $source $REMOTE_DIR/$target.upload-$revision" >> "$batch"
printf '%s\n' "rename $REMOTE_DIR/$target.upload-$revision $REMOTE_DIR/$target" >> "$batch"
done < "$files"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
php bin/release-brand.php --status "$PRELAND_URL"
- name: Upload over SFTP
env:
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
@@ -66,9 +85,9 @@ jobs:
trap 'rm -f "$known_hosts" "$batch" "$files" "$dirs"' EXIT
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
# Наборы `/N/` на stage — редактируемый контент. Кодовая доставка их
# намеренно не перезаписывает: актуальное содержимое stage фиксируют в Git
# отдельной синхронизацией по задаче.
git ls-files | grep -vE '^(\.gitea/|README\.md$|AGENTS\.md$|CLAUDE\.md$|\.gitignore$|requisites\.html$|config\.json$|[0-9]+/)' > "$files"
# намеренно не перезаписывает. Обновление набора через админку не требует Git.
# Положительный список: admin/tests/bin/планы/секреты не публикуются.
php bin/release-assets.php --files | grep -vE '^(config\.json$|[0-9]+/)' > "$files"
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
while read -r file; do
@@ -83,9 +102,36 @@ jobs:
done < "$files"
sort -u "$dirs" | while read -r dir; do printf '%s\n' "-mkdir $REMOTE_DIR/$dir" >> "$batch"; done
while read -r file; do printf '%s\n' "put -p $file $REMOTE_DIR/$file" >> "$batch"; done < "$files"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
echo "залито файлов: $(wc -l < "$files")"
# api#326/#327: сначала совместимый PHP-код, затем точечная миграция UTM.
# chob→banch меняет только токен имени; веса/default владельца сохраняются.
# Остальные поля владельца и их JSON-типы сохраняются.
- name: Migrate numeric UTM in preserved host config
env:
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }}
SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }}
SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }}
run: |
set -eu
umask 077
known_hosts=$(mktemp); batch=$(mktemp); config=$(mktemp)
trap 'rm -f "$known_hosts" "$batch" "$config"' EXIT
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
changed=0
php bin/ensure-utm-config.php "$config" || changed=$?
case "$changed" in
0) ;;
10) revision=$(git rev-parse HEAD)
printf '%s\n' "put -p $config $REMOTE_DIR/config.json.upload-$revision" "rename $REMOTE_DIR/config.json.upload-$revision $REMOTE_DIR/config.json" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST" ;;
*) exit "$changed" ;;
esac
- name: Check the page
run: |
set -eu
@@ -94,10 +140,29 @@ jobs:
echo "$api"
case "$api" in *'zoomzilla.ru\/api\/images\/public\/create'*) ;; *) echo "на странице нет адреса загрузки Zoomzilla"; exit 1 ;; esac
printf '%s' "$page" | grep -q 'claim?key=' || { echo "переход не на claim"; exit 1; }
curl -fsS --max-time 30 "$PRELAND_URL/1/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован первый набор примеров"; exit 1; }
curl -fsS --max-time 30 "$PRELAND_URL/2/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован второй набор примеров"; exit 1; }
page2=$(curl -fsS --max-time 30 "$PRELAND_URL/?indpic=2&nc=$(date +%s)")
printf '%s' "$page2" | grep -q '2/01_mob_1-source.avif' || { echo "indpic=2 не использует второй набор примеров"; exit 1; }
# Список наборов берётся из runtime config, а не фиксируется как /1/ и /2/.
# Полная проверка изображений ниже; одиночный HTTP200 недостаточен.
php bin/release-assets.php --remote "$PRELAND_URL"
php bin/release-brand.php --status "$PRELAND_URL"
- name: Remove previously published internal files
env:
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }}
SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }}
SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }}
run: |
set -eu
umask 077
known_hosts=$(mktemp); batch=$(mktemp)
trap 'rm -f "$known_hosts" "$batch"' EXIT
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
# Только точно известные служебные файлы прежнего deploy, без рекурсии.
printf '%s\n' "-rm $REMOTE_DIR/requisites.html" > "$batch"
git ls-files tests bin docs/superpowers | while read -r file; do
printf '%s\n' "-rm $REMOTE_DIR/$file" >> "$batch"
done
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
- name: Remove obsolete demo directories
env:
@@ -121,4 +186,4 @@ jobs:
done
printf '%s\n' "-rmdir $REMOTE_DIR/images/$set" >> "$batch"
done
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" "$SFTP_USER@$SFTP_HOST" < "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+66 -2
View File
@@ -1,5 +1,18 @@
# Инструкции агентов для `preland`
## Поручение 25.10.2026 — preland#21
Боевые цели теперь `zoompixel.ru` на основном сервере и `zoomila.ru`/`zoomika.ru`
на двух REG.RU-хостингах. Владелец явно разрешил первоначальное размещение этих трёх
целей; прежняя заморозка #4 для этой задачи снята. Единственный активный `.online` —
`zoomstage.online` (stage). `zoompixel.online`, `zoomika.online`, `zoomila.online` отключены,
каталоги убраны из рабочих FTP-папок с резервным сохранением; не включать без поручения.
На обоих REG.RU-хостингах владельцу нужен SFTP или FTP; существующие доступы описаны
в актуальном разделе README, пароли остаются в REG.RU. Доменные готовые SVG — в
`brands/`, существующий runtime-config сохранять. Обычный автодеплой остаётся только
на stage; новое поручение не включает production-автодеплой. При расхождении со старыми
разделами ниже действует это датированное решение и задача #21.
Область действия — репозиторий `preland/`. Общие правила рабочей области находятся в
[AGENTS.md](../AGENTS.md) и [CLAUDE.md](../CLAUDE.md); канонический процесс и каталог ролей —
[в общей политике](../.known-base/Соглашения/Процесс%20командной%20разработки%20и%20каталог%20ролей.md).
@@ -32,8 +45,8 @@
создавать target-specific конфиги или `config.json.example`.
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
Git отдельной синхронизацией по задаче.
актуальные примеры нельзя перезаписывать Git-версией. Снимок можно сохранить в
Git по задаче; #22 не требует этого для обычного изменения runtime-набора.
- Коммит в `staging` запускает SFTP-автодеплой только на zoomstage.online; откат через `git revert`
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
@@ -45,3 +58,54 @@
## Актуальная маршрутизация (19.10.2026, preland#4)
До отдельного поручения владельца выкатывать только staging → zoomstage.online. Production zoompixel.ru/.online не обновлять. preland.anton.barbariskatest.ru больше не используется, не является целью и не должен упоминаться как требующий выпуска. CI запускает только stage. Сохранение runtime config и stage-наборов /N/ обязательно. Работа и решения — через Gitea.
## Актуальные ресурсы (21.10.2026, preland#11)
- Git main и staging содержат одно проверенное дерево. Источники: текущая zoomzilla.ru — два логотипа Zoomzilla, четыре PDF, favicon и реквизиты; zoomstage.online — все файлы наборов /1 и /2. История Git не переписывается; известная старая заглушка запрещена в рабочей поставке.
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
## Доменный бренд и минимальный подвал (21.10.2026, preland#12)
Новое поручение владельца заменяет часть требований #11: в подвале остаются логотип и копирайт, без реквизитов и платёжных значков. Копирайт использует текущий HTTP_HOST без порта/trailing-dot, отдельно от config.host API; непроверенные Host и X-Forwarded-Host не выводятся.
Логотипы — готовые SVG-контуры, не текст на лету. Для zoomstage.online утверждено имя ZoomStage, слитно без зоны, обе темы. Источники лежат в brands/zoomstage.online/light.svg и dark.svg; release/brands.json содержит явную цель, имя и SHA. В docs/logo.svg и logo-alt.svg хранятся правильные canonical stage-копии для полного переноса. Их размеры182×47 совпадают с viewBox, чтобы загрузка SVG не меняла геометрию.
Обычный bin/release-assets.php --files исключает оба logo-alias из записи; runtime cache-version рассчитывается по реальному файлу хоста. Отдельный шаг release-brand.php --status разрешает только заранее согласованную миграцию #12:0 — оба SVG уже правильные, записи нет;10 — каждый файл имеет свой известный старый или новый SHA, можно закончить миграцию (включая частичный put);1 — неизвестный домен/хеш, остановка без подмены. --files выдаёт ровно утверждённые source→target. Иные логотипы не перезаписывать общим выкатом. Новые production-домены требуют отдельного добавления готовых пар и явного первоначального размещения; текущий stage-процесс их не активирует.
primary guard теперь сверяет PDF/favicon, а не заставляет возвращать Zoomzilla и реквизиты на преленд. Реквизиты не отображаются и больше автоматически не синхронизируются; хостовый config и его существующие значения не меняются этим поручением. Устаревшие requisites.html и patch-скрипт удалены из поставки. Статический manifest183, отдельный каталог брендов проверяется по собственным SHA. История #11 сохранена; её прежние инструкции о видимом бренде/реквизитах заменены этим датированным решением.
Перед выпуском: девять tests/check-*.php и lint, --check/--primary/--stage-sets, оба бренда шапки/подвала на desktop/mobile, перенос Host, отсутствие карт/реквизитов, холодная/медленная загрузка, реальные локальные Preland→Tour→Join/error/retry и геометрия. Production-преленды по #4 не включать; выпускать только staging→zoomstage.online. API продуктовый код не меняется.
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
Это решение явно заменяет требование #11 о предварительном импорте изменённых
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
обновление контента админкой не требует commit/push. Исторические записи `/N/`
в `release/assets.json` остаются снимком, не источником истины runtime.
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
затем декодируют все 90 обязательных изображений каждого используемого набора,
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
вложенных каталогов и лишних элементов выполнить на фактическом webroot
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
+66 -2
View File
@@ -1,5 +1,18 @@
# Инструкции агентов для `preland`
## Поручение 25.10.2026 — preland#21
Боевые цели теперь `zoompixel.ru` на основном сервере и `zoomila.ru`/`zoomika.ru`
на двух REG.RU-хостингах. Владелец явно разрешил первоначальное размещение этих трёх
целей; прежняя заморозка #4 для этой задачи снята. Единственный активный `.online` —
`zoomstage.online` (stage). `zoompixel.online`, `zoomika.online`, `zoomila.online` отключены,
каталоги убраны из рабочих FTP-папок с резервным сохранением; не включать без поручения.
На обоих REG.RU-хостингах владельцу нужен SFTP или FTP; существующие доступы описаны
в актуальном разделе README, пароли остаются в REG.RU. Доменные готовые SVG — в
`brands/`, существующий runtime-config сохранять. Обычный автодеплой остаётся только
на stage; новое поручение не включает production-автодеплой. При расхождении со старыми
разделами ниже действует это датированное решение и задача #21.
Область действия — репозиторий `preland/`. Общие правила рабочей области находятся в
[AGENTS.md](../AGENTS.md) и [CLAUDE.md](../CLAUDE.md); канонический процесс и каталог ролей —
[в общей политике](../.known-base/Соглашения/Процесс%20командной%20разработки%20и%20каталог%20ролей.md).
@@ -32,8 +45,8 @@
создавать target-specific конфиги или `config.json.example`.
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
Git отдельной синхронизацией по задаче.
актуальные примеры нельзя перезаписывать Git-версией. Снимок можно сохранить в
Git по задаче; #22 не требует этого для обычного изменения runtime-набора.
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
@@ -42,3 +55,54 @@
План, журнал и база знаний ведутся в корне рабочей области по общим правилам. Ссылки на
политику и документацию не подключают MCP и не дают разрешения на публикацию или выкат.
## Актуальные ресурсы (21.10.2026, preland#11)
- Git main и staging содержат одно проверенное дерево. Источники: текущая zoomzilla.ru — два логотипа Zoomzilla, четыре PDF, favicon и реквизиты; zoomstage.online — все файлы наборов /1 и /2. История Git не переписывается; известная старая заглушка запрещена в рабочей поставке.
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
## Доменный бренд и минимальный подвал (21.10.2026, preland#12)
Новое поручение владельца заменяет часть требований #11: в подвале остаются логотип и копирайт, без реквизитов и платёжных значков. Копирайт использует текущий HTTP_HOST без порта/trailing-dot, отдельно от config.host API; непроверенные Host и X-Forwarded-Host не выводятся.
Логотипы — готовые SVG-контуры, не текст на лету. Для zoomstage.online утверждено имя ZoomStage, слитно без зоны, обе темы. Источники лежат в brands/zoomstage.online/light.svg и dark.svg; release/brands.json содержит явную цель, имя и SHA. В docs/logo.svg и logo-alt.svg хранятся правильные canonical stage-копии для полного переноса. Их размеры182×47 совпадают с viewBox, чтобы загрузка SVG не меняла геометрию.
Обычный bin/release-assets.php --files исключает оба logo-alias из записи; runtime cache-version рассчитывается по реальному файлу хоста. Отдельный шаг release-brand.php --status разрешает только заранее согласованную миграцию #12:0 — оба SVG уже правильные, записи нет;10 — каждый файл имеет свой известный старый или новый SHA, можно закончить миграцию (включая частичный put);1 — неизвестный домен/хеш, остановка без подмены. --files выдаёт ровно утверждённые source→target. Иные логотипы не перезаписывать общим выкатом. Новые production-домены требуют отдельного добавления готовых пар и явного первоначального размещения; текущий stage-процесс их не активирует.
primary guard теперь сверяет PDF/favicon, а не заставляет возвращать Zoomzilla и реквизиты на преленд. Реквизиты не отображаются и больше автоматически не синхронизируются; хостовый config и его существующие значения не меняются этим поручением. Устаревшие requisites.html и patch-скрипт удалены из поставки. Статический manifest183, отдельный каталог брендов проверяется по собственным SHA. История #11 сохранена; её прежние инструкции о видимом бренде/реквизитах заменены этим датированным решением.
Перед выпуском: девять tests/check-*.php и lint, --check/--primary/--stage-sets, оба бренда шапки/подвала на desktop/mobile, перенос Host, отсутствие карт/реквизитов, холодная/медленная загрузка, реальные локальные Preland→Tour→Join/error/retry и геометрия. Production-преленды по #4 не включать; выпускать только staging→zoomstage.online. API продуктовый код не меняется.
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
Это решение явно заменяет требование #11 о предварительном импорте изменённых
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
обновление контента админкой не требует commit/push. Исторические записи `/N/`
в `release/assets.json` остаются снимком, не источником истины runtime.
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
затем декодируют все 90 обязательных изображений каждого используемого набора,
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
вложенных каталогов и лишних элементов выполнить на фактическом webroot
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
+125 -28
View File
@@ -1,39 +1,79 @@
# Преленд zoomzilla
## Боевые домены — поручение 25.10.2026, preland#21
Три боевые цели: `zoompixel.ru` на основном сервере, `zoomila.ru` и `zoomika.ru`
на двух отдельных хостингах REG.RU. Это отдельное разрешение владельца на первоначальное
размещение/обновление этих целей после заморозки #4. Единственный активный `.online` —
`zoomstage.online`, staging прелендингов. `zoompixel.online`, `zoomika.online` и
`zoomila.online` отключены по дополнительному решению владельца; их регистрация сохранена.
Рабочие FTP/SFTP-каталоги не должны содержать директории отключённых `.online`.
| Домен | Размещение | Пользователь SFTP/FTP | Каталог сайта |
|---|---|---|---|
| `zoomika.ru` | `server197.hosting.reg.ru`, `31.31.196.183` | `u3695919` | `/www/zoomika.ru` относительно домашнего каталога |
| `zoomila.ru` | `server124.hosting.reg.ru`, `37.140.192.195` | `u3695920` | `/www/zoomila.ru` относительно домашнего каталога |
| `zoompixel.ru` | основной сервер, `/srv/preland/domains/zoompixel.ru` | существующий административный доступ | отдельный контейнер `preland-zoompixel-ru-1` |
REG.RU выдаёт SFTP/SSH и FTP отдельно: SFTP использует порт 22 и пароль из блока
«Доступ в панель управления, SSH, SFTP»; FTP — порт 21 и отдельный пароль из блока
«Доступ к FTP». Пароли смотреть в кабинете REG.RU → нужный хостинг → «Доступы»;
не помещать в Git и журналы. Оба аккаунта уже созданы провайдером, сбрасывать пароли
для размещения не требуется. Полные домашние каталоги — `/var/www/<пользователь>/data`.
В `www` каждого REG.RU-аккаунта оставлен только соответствующий `.ru`. Сайты `.online`
выключены через ispmanager, их исходные файлы и установочные архивы сохранены вне webroot
в скрытом `.preland21-backup/` (каталог `site`, файлы `release.zip`, `initial-index.html`).
На основном сервере `zoompixel.online` отвечает HTTP/HTTPS 410; его контейнер удалён,
сервис исключён из Compose, исходники сохранены в `/srv/preland/backups/preland21-disabled-online/site`.
Логотипы для обеих тем — готовые SVG в `brands/<домен>/`, реестр — `release/brands.json`.
При первоначальном размещении копировать только положительный список `releaseFiles()`
и проверенные статические ресурсы (включая `/1`, `/2`), затем доменную пару в
`docs/logo.svg`/`docs/logo-alt.svg`. Служебные `bin/`, `tests/`, `.git/`, планы и
установочные архивы в webroot не входят. Архивы REG.RU хранить вне `/www`.
У новых пустых сайтов исходный config — проверенный stage-снимок; существующий
`zoompixel.ru/config.json` сохраняется байт-в-байт, старые UTM нормализуются в памяти.
Перед трафиком обязательны доверенный TLS (самоподписанный сертификат REG.RU не подходит),
HTTP→HTTPS, проверка двух тем/ширин, примеров, upload→claim→Tour→Join с UTM,
видимой ошибки и повторной попытки. Точный состав, коммиты, результаты и ограничения — #21.
Обычная автоматическая доставка по-прежнему только `staging` → `zoomstage.online`;
данное первоначальное размещение не включает production-автодеплой по push `main`.
Исторические разделы ниже описывают прежние цели; при расхождении действует этот раздел.
Одностраничный лендинг перед основным сайтом (`zoomzilla.ru`): посетитель с рекламы выбирает фото прямо на
преленде, файл сразу уходит в API сайта, а сам посетитель переходит на сайт — на страницу обработки этого фото.
Варианты страницы и сайта (A/B) задаются метками в адресе. Код — обычный PHP без фреймворка и сборки; переводы —
маленькая заглушка `Yii::t` в `i18n.php`.
Активные production: `https://zoompixel.ru`, `https://zoompixel.online`; текущая цель выката — только `https://zoomstage.online`. Пример ссылки входа:
`https://zoomstage.online/?indpic=1&theme=dark&degr=2&registration=after&bchoice=a&tp=1&utm_source=test&subid=CLICK1`.
`https://zoomstage.online/?indpic=e9st1mm12b&theme=e9st1mm12b&degr=e9st2mm12b&reg=e9st2mm12b&banch=e9st2mm12b&tp=e9st1mm12b&utm_source=test&subid=CLICK1`.
## Как работает
### Метки
### Метки (api#326)
`index.php` разбирает параметры адреса по разделу `utm` из `config.json`. Для каждой настроенной метки:
Из публичного значения учитывается только пятый символ: цифра1–9 выбирает вариант,0 на Preland выбирает его по прежним `weights`. Отсутствующий, короткий, массивный и невалидный ввод получает `default`. Открытые `1`, `rnd`, `dark`, старые имена `registration/bchoice/chob/chb` больше не выбирают варианты. Точная длина входной строки не ограничена; Unicode-шум не изменяет позицию управляющего символа.
- значение из адреса принимается, если оно есть в `weights`;
- `rnd` — случайное значение по весам (`weights`), так делится трафик между вариантами;
- неизвестное значение отбрасывается, отсутствующее — заменяется на `default`.
| Метка | Числовые значения |
|---|---|
| `indpic` | номер настроенного набора1–9 (`/1/`, `/2/` и т.д.) |
| `theme` |1 dark,2 light |
| `degr` | номер настроенной группы1–9 |
| `reg` |1 before (PIN до оплаты),2 after |
| `banch` |1 без попапа банка,2 с попапом |
| `tp` | номер настроенной тарифной группы1–9 |
| `cons` | номер группы согласий1–9; существование проверяет API |
| `focus` |1 face,2 center |
| `part` |1 показывать ссылку «Для бизнеса» на сайте,2 скрывать; Preland ссылку не показывает |
Ненастроенные параметры (`utm_source`, `keyword`, `subid` и любые другие) проходят как есть. Перед переходом
Preland всегда формирует полный resolved-набор своих восьми меток: явное допустимое значение остаётся, `rnd`
разрешается по весам, отсутствующее получает `default`. Вместе с внешними рекламными параметрами этот набор
уходит в `claim`, а затем сайт переносит его в `upscale`; ни один из восьми параметров не должен пропадать только
потому, что был назначен default.
`config.json` хранит публичные числовые `weights/default`. Внутри PHP сохраняются прежние semantic enum. Runtime-конфиг мигрируется `bin/ensure-utm-config.php`: только согласованные UTM, с сохранением весов, default и JSON-типов соседних полей. Скрипт идемпотентен; старые focus/cons patch-скрипты больше не вызываются доставкой.
| Метка | Что задаёт | Где |
|---|---|---|
| `indpic` | картинка-пример на преленде | корневой каталог сета `/1/`, `/2/` и т. д. |
| `theme` | `light` / `dark` | преленд и сайт |
| `degr` | пресет ухудшения превью «До» | сайт (#42) |
| `registration` | `before` — PIN до оплаты, `after` — после | сайт, страница результата |
| `bchoice` | попап выбора банка (`a` — показать) | сайт, оплата (#44) |
| `chb` | галочки согласия на странице оплаты (`no` — не обязательны) | сайт, оплата |
| `tp` | группа тарифов | сайт, cpanel «Группы тарифов (tp)» |
| `focus` | первоначальная область лупы Join: `center`/`face` | сайт, cpanel после `tp` |
В 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.
Чужие параметры (`utm_source`, `keyword`, `subid` и любые другие) сохраняют значения после обычного URL-кодирования. Технические `key/id/stage/shown_theme` не маскируются; внешний `key` не может подменить одноразовый ключ загрузки. `shown_theme` берётся из фактически показанной темы непосредственно при переходе.
### Загрузка и переход на сайт
@@ -76,7 +116,7 @@ Preland всегда формирует полный resolved-набор сво
| `requisites` | нет | реквизиты в подвале — список строк, каждая отдельным абзацем (текст экранируется). Важнее `requisites.html`; без обоих — заглушка «ИП Иванов» |
| `assetsUriPrefix` | нет | откуда брать статику. Пусто — из своего каталога; если на хостинге нет каталога `images`, статика берётся с сайта (`https://<host>`) |
| `fontsUrl`, `faviconUrl`, `spritePath` | нет | адреса шрифтов, иконок и SVG-спрайта, если они не в `assetsUriPrefix` |
| `logoAltExt` | нет | расширение второго логотипа (`svg` по умолчанию) |
| `logoAltExt` | устарело | После preland#11 обе темы используют проверенные SVG Zoomzilla; расширение не меняется runtime-конфигом. |
| `title` | нет | заголовок страницы («Улучшение изображения») |
| `default_language` | нет | сейчас не действует: язык всегда `ru` |
@@ -99,15 +139,15 @@ Preland хранит наборы ровно как Index, поэтому их
N/05_...
```
`indpic=N` выбирает корневой каталог `/N/`. Набор копируется с Index в корень
`indpic=abcdNefghi` выбирает корневой каталог `/N/`. Набор копируется с Index в корень
домена целиком: без переименования, вложения в `images/` и промежуточных
каталогов. Устаревшие `images/demo*` не являются частью поставки.
Для `zoomstage.online` обычный deploy ветки `staging` намеренно **не загружает**
корневые числовые каталоги `/N/`: это редактируемый контент stage, а не артефакт
кодовой доставки. Workflow продолжает проверять их доступность HTTP-smoke. Чтобы
зафиксировать обновлённый набор, сначала синхронизируйте его со stage в Git по
отдельной задаче; следующая заливка не перетрёт его старой ревизией.
кодовой доставки. По #22 workflow проверяет их полный контракт по фактическому
config через runtime guard. Импорт изменённого набора в Git не требуется;
следующая заливка не перетрёт его старой ревизией.
## Деплой на произвольный домен
@@ -145,8 +185,8 @@ N/05_...
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'https://<host>/api/images/public/claim?key=x'
# 302 на https://<host>/ — точка передачи на сайте есть
```
В браузере: открыть `https://<домен>/?indpic=1&theme=dark&subid=TEST`, выбрать фото — адрес сменится на
`https://<host>/public/upscale?id=…&indpic=1&theme=dark&subid=TEST…` (без `key=`), пойдёт обработка.
В браузере: открыть `https://<домен>/?indpic=abcd1efghi&theme=abcd1efghi&subid=TEST`, выбрать фото — адрес сменится на
`https://<host>/public/upscale?id=…&indpic=<новый шум с1>&theme=<новый шум с1>&subid=TEST…` (без `key=`), пойдёт обработка.
Это настоящая загрузка и обработка на сайте.
## Автодеплой (Gitea Actions)
@@ -173,3 +213,60 @@ Workflow `.gitea/workflows/deploy.yml` содержит единственную
## Android: фото и файлы (2026-10-19)
Основная кнопка выбирает image/*; отдельная ссылка «Выбрать файлы (PDF и др.)» открывает общий picker без ограничения image/*. PDF в подписи скрывается по runtime API-policy. iOS/iPad/Desktop сохраняют прежний общий выбор. +много сохраняется в одной компактной mobile-строке. components/browser-upload.php и CSS upload-picker-links.php — лёгкие inline-копии соответствующих компонентов API: отдельный PHP-хост не использует Yii AssetManager, новый внешний запрос не добавляется. При изменении общего контракта синхронизировать обе копии; API mobile-picker.cjs покрывает обе реальные страницы. Android byte-copy FileProvider сохраняет исходные байты/имя/MIME и ограничен max_file_size_bytes; до ответа policy используется консервативный15MB предел копирования. UTM/client_key/claim/config.json не меняются.
## Актуальные ресурсы (21.10.2026, preland#11)
- Git main и staging содержат одно проверенное дерево. Источники: текущая zoomzilla.ru — два логотипа Zoomzilla, четыре PDF, favicon и реквизиты; zoomstage.online — все файлы наборов /1 и /2. История Git не переписывается; известная старая заглушка запрещена в рабочей поставке.
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
Повторная проверка выпуска: php bin/release-assets.php --stage-requisites https://zoomstage.online проверяет именно runtime config; успешная загрузка requisites.html сама по себе недостаточна. Все обязательные SFTP-команды выполняются через batch -b и прекращают выкат при ошибке put.
## Доменный бренд и минимальный подвал (21.10.2026, preland#12)
Новое поручение владельца заменяет часть требований #11: в подвале остаются логотип и копирайт, без реквизитов и платёжных значков. Копирайт использует текущий HTTP_HOST без порта/trailing-dot, отдельно от config.host API; непроверенные Host и X-Forwarded-Host не выводятся.
Логотипы — готовые SVG-контуры, не текст на лету. Для zoomstage.online утверждено имя ZoomStage, слитно без зоны, обе темы. Источники лежат в brands/zoomstage.online/light.svg и dark.svg; release/brands.json содержит явную цель, имя и SHA. В docs/logo.svg и logo-alt.svg хранятся правильные canonical stage-копии для полного переноса. Их размеры182×47 совпадают с viewBox, чтобы загрузка SVG не меняла геометрию.
Обычный bin/release-assets.php --files исключает оба logo-alias из записи; runtime cache-version рассчитывается по реальному файлу хоста. Отдельный шаг release-brand.php --status разрешает только заранее согласованную миграцию #12:0 — оба SVG уже правильные, записи нет;10 — каждый файл имеет свой известный старый или новый SHA, можно закончить миграцию (включая частичный put);1 — неизвестный домен/хеш, остановка без подмены. --files выдаёт ровно утверждённые source→target. Иные логотипы не перезаписывать общим выкатом. Новые production-домены требуют отдельного добавления готовых пар и явного первоначального размещения; текущий stage-процесс их не активирует.
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 продуктовый код не меняется.
Дополнение приёмки #12: tests/e2e/brand-footer.cjs проверяет именно видимый SVG по центру шапки, а не только headerbox. PUBLISHED=1 использует zoomstage.online (или PRELAND_URL); без него — изолированный pre301.localhost QA proxy. Запускать в имеющемся Docker Playwright с /candidate=репозиторий,/qa=артефакты; POST/PUT блокируются. Пропущенное при compact CSS правило Bootstrap navbar>.container восстановлено во всех3CSS: API уже имеет его из полного Bootstrap. Это устраняет переход logo y0→13.5px, который рамочный тест не замечал.
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
Это решение явно заменяет требование #11 о предварительном импорте изменённых
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
обновление контента админкой не требует commit/push. Исторические записи `/N/`
в `release/assets.json` остаются снимком, не источником истины runtime.
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
затем декодируют все 90 обязательных изображений каждого используемого набора,
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
вложенных каталогов и лишних элементов выполнить на фактическом webroot
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
api#347: part — девятая UTM, пятый символ1/2, default1. Preland только передаёт её на сайт и никогда не показывает ссылку «Для бизнеса». Отсутствующая группа/веса сохраняют fallback1; случайный выбор использует существующий порядок и настроенные веса. Runtime config автоматически не дополняется. Старые сохранённые ссылки конструктора получают part1 без изменения шума остальных меток; старые заметки читаются с пустой заметкой part.
+6
View File
@@ -0,0 +1,6 @@
tests
vendor
state
secret.env
.env
*.log
+5
View File
@@ -0,0 +1,5 @@
/vendor/
/state/
/secret.env
/.env
/*.log
+23
View File
@@ -0,0 +1,23 @@
FROM php:8.3-apache-bookworm@sha256:cbd3b3d9011668443d54ab40ec31db56c73d91b4c8198935d639aa80d2d815c1
RUN apt-get update \
&& apt-get install -y --no-install-recommends libmagickwand-dev libzip-dev unzip ca-certificates git \
&& docker-php-ext-install zip \
&& printf '\n' | pecl install imagick-3.8.0 \
&& docker-php-ext-enable imagick \
&& a2enmod rewrite headers \
&& groupmod -g 987 www-data && usermod -u 997 -g 987 www-data \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2@sha256:b6dcd72ca98fa19d0e5b8476de179859704891fad06dd9e0b96833d4c430a5fc /usr/bin/composer /usr/local/bin/composer
RUN docker-php-ext-install pcntl
WORKDIR /app
COPY composer.* ./
RUN COMPOSER_IPRESOLVE=4 composer install --no-dev --no-interaction --prefer-dist --no-progress --optimize-autoloader
COPY . /app
COPY deploy/apache.conf /etc/apache2/sites-available/000-default.conf
COPY deploy/php.ini /usr/local/etc/php/conf.d/preland-admin.ini
RUN mkdir -p /var/lib/preland-admin && chown 997:987 /var/lib/preland-admin
ENV ADMIN_STATE_DIR=/var/lib/preland-admin
EXPOSE 80
CMD ["apache2-foreground"]
+273
View File
@@ -0,0 +1,273 @@
# Админка прелендингов — preland#22
Отдельное PHP-приложение для `admin.zoomstage.online`. Формы редактируют фактические
`config.json` в общей таблице; сохранённые настройки можно скопировать в выбранные домены. ZIP indpic
проверяется по контракту CPanel и доставляется на выбранные хостинги. Изменение контента
не требует Git/push/deploy; `admin/` не входит в публичную поставку прелендингов.
Реквизиты и устаревший `utm.chb` скрыты и защищены от записи. Неизвестные поля показаны только для чтения и сохраняются.
Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории,
копии и фактический статус — в базе знаний, основной реестр — preland#22.
## Сводка и конструктор ссылки
Первый экран — редактор фактических конфигов всех подключений. Основной сайт,
веса и default редактируются в соответствующей колонке. Веса хранятся в процентах:
0..100 с двумя знаками; сумма группы — 100% либо все веса — 0%. Default выбирается radio обычного
начертания. Отсутствующая группа добавляется только явно. «Сохранить» и «Отмена»
работают отдельно для каждого домена; ошибка и конфликт сохраняют черновик.
Перечитывание другой колонки сохраняет DOM, фокус и курсор. После конфликта нужно
явно загрузить сохранённое либо отменить черновик. Версии CAS остаются внутренними;
даты заметок и версии конфигов в таблице не показываются. Дата последнего чтения с хостинга видна у каждого домена.
Под таблицей — копирование сохранённых host и девяти канонических UTM из одного
источника в несколько получателей. Источник исключён из получателей. Черновики
источника и выбранных получателей сначала нужно сохранить или отменить. Неизвестные
поля, реквизиты, заметки и файлы ZIP не копируются. Отсутствующая группа источника
оставляет группу получателя. Источник и каждая цель проверяются по своей версии;
сохранение/копирование считаются завершёнными после результата задания и перечитывания.
Ошибки отдельных целей видны в колонке и журнале; успешные цели не повторяются.
Конструктор работает отдельно: значения или «Рандом» в девяти списках, затем
«Сформировать и сохранить». Последний выбор и точный URL сохраняются на сервере
отдельно по ID домена, восстанавливаются после перезагрузки. Изменение списка скрывает
старую ссылку до явного сохранения. Значащая цифра стоит на пятой позиции метки,
`0` означает выбор по весам. Конструктор не меняет config, default, веса или наборы,
не создаёт задания; сохранённый выбор доступен и при недоступном хостинге.
`GET /api/link-builder?target=ID` читает выбор, `POST /api/link-builder` сохраняет
`{target,selection}` с ровно девятью каноническими UTM-ключами. Действуют общий вход
и CSRF. Состояние находится в приватном `state/link-builder-ID.json` с режимом0600.
При переименовании домена меняется origin ссылки, выбор и сохранённые метки остаются.
Неизвестный в текущем config номер сопровождается предупреждением;
конструктор не исправляет конфигурацию автоматически.
## Компактная сводка и заметки — preland#23 / #24
С версии preland#25 ячейки содержат inline-редактор, описанный выше.
В блоке каждой UTM верхняя строка содержит название один раз и общую заметку
в объединённой ячейке доменов. Ниже — пустая первая ячейка и значения доменов.
Устаревший `utm.chb` скрыт в сводке и редакторе; остальные неизвестные поля видны.
Редактор заметки —
растягиваемая по высоте textarea на всю ширину объединённой ячейки доменов; сохранение явное.
Черновик сохраняется при обновлении колонок или ошибке запроса. При конфликте
сохранения нужно явно загрузить серверный текст; чужой текст молча не заменяется.
`GET /api/utm-notes` читает девять заметок, `POST /api/utm-notes` принимает
`{marker,text,version}`. Авторизация/CSRF общие; версия отдельная у каждой заметки,
запись защищена CAS. Plain text до10000 Unicode-символов, пустой текст очищает
заметку. Файл `state/utm-notes.json` приватный0600, хранится независимо от
config.json, подключений, конструктора ссылок и заданий. Заметки не доставляются
на хостинги и сохраняются после замены контейнера.
## Состав и доступы
`compose.yaml` запускает web и отдельный worker из одного неизменяемого образа. Нужны
PHP8.3, ZIP, Imagick с AVIF/WebP/JPEG и phpseclib3 из зафиксированного Composer lock.
На Windows ничего устанавливать не требуется: сборка и проверки выполняются Docker.
На сервере `/srv/preland-admin`: `compose.yaml`, `.env` с `ADMIN_IMAGE`, закрытый
`secret.env`, `state/`, `secrets/` и `deploy/`. В `secret.env` задаются `ADMIN_USERNAME`
и `ADMIN_PASSWORD_HASH`; bcrypt-значение заключается в одинарные кавычки, чтобы Compose
не интерполировал `$`. Пароль хешируется через `bin/password-hash.php` со stdin.
В HTTP Secure-cookie включена по умолчанию; `ADMIN_COOKIE_SECURE=0` разрешена только
на изолированном HTTP-стенде.
PHP-сессии хранятся в `state/sessions` с режимом0700, файлы имеют режим0600.
Пересоздание web-контейнера сохраняет вход; срок бездействия остаётся3600секунд.
Хранилище сессий не допускает относительный путь, symlink или публичный каталог.
При первом переходе со старого ephemeral backing существующие session-файлы
нужно перенести приватно до пересоздания; сам код не мигрирует старый `/tmp`.
Необязательная галочка «Запомнить меня на 90 дней» создаёт persistent cookie
`preland_admin` с HttpOnly/Secure/SameSite=Strict. Срок фиксируется при входе,
чтения его не продлевают. Данные такого входа лежат отдельно в
`state/remember-sessions`0700, файлы0600. Без галочки остаются session-only cookie
и прежний час бездействия. Анонимные обращения не создают долгоживущих файлов.
«Выйти» удаляет серверный сеанс и cookie немедленно; пароль не сохраняется.
UID/GID приложения — 997:987. Приложение получает приватный state и ровно два local
mount: `/targets/zoompixel.ru`, `/targets/zoomstage.online`. Весь `/srv/preland`, Docker
socket, root SSH key и база основного сайта в приложение не передаются. Права публикации
на каждом корне проверяются реальным временным файлом/rename при проверке подключения;
нельзя включать запись до успешной проверки.
REG.RU ограничивает размер SSH exec-команды: длинный inline helper разрывал
соединение после успешного входа. Доверенный helper передаётся сжатым zlib;
финальная команда ограничена7000байт, большие payload идут в закрытый SFTP-inbox.
На удалённом PHP требуется `gzinflate`; отсутствие возвращает понятную ошибку.
Содержимое inbox удаляется и при нормальном завершении, и резервно при обрыве.
Два REG.RU-хостинга подключаются SFTP/SSH с их обычными ограниченными учётными записями.
Нужны абсолютный корень, host key либо SHA256 fingerprint, пароль или приватный ключ,
доступный через stdin CLI `bin/target-import.php`. Секрет хранится 0600 в приватном state,
по API не возвращается. На удалённом хосте нужен `php` CLI >=8.1 с `fsync`; удалённый
Imagick не нужен. Замена реквизитов сбрасывает проверку подключения.
## Работа через формы
1. Изменить основной сайт или UTM в нужной колонке, проверить проценты и default,
нажать «Сохранить» под колонкой. Остальные поля сохраняются; ошибка оставляет черновик.
2. Для копирования выбрать источник и получателей под таблицей. Сначала сохранить
либо отменить их черновики, затем нажать кнопку с числом получателей.
3. Проверить итог каждой цели в колонках и журнале. При конфликте явно перечитать
сохранённые настройки; неизвестный результат требует проверки журнала и фактического состояния.
4. Раздел indpic имеет собственные «Открытый домен» и checkbox целей ZIP/проверки.
Выбор копирования настроек на эти цели не влияет. Резерв относится к открытому домену indpic.
5. Загрузить ZIP или проверить SFTP-папку, при необходимости назначить новый default
в основной таблице: его номер должен присутствовать в весах, допускается вес0.
Номера — 1–9, ZIP содержит папку номера и ровно90 изображений установленных форматов/
размеров; лимит8МиБ на файл,64МиБ распакованного набора. При нулевой сумме весов rnd
использует первый ключ weights по порядку config.json; только cons использует default.
Имена numeric/legacy UTM адаптируются отдельно для каждой цели, конфликтующие aliases
блокируются. Удаление набора из реестра оставляет файлы; используемый номер удалить нельзя.
## Ручные варианты и независимость — preland#30
Во всех девяти UTM ссылка «Добавить вариант» создаёт пустую строку: номер, вес и
неотмеченный default. До нажатия пустых строк нет. Существующие номера показаны
текстом; новые вводятся вручную (1–9, для part только 1 или 2). После сохранения новый номер тоже становится
обычным текстом. Нулевой вес сохраняется. Отсутствующую группу можно создать через
ту же ссылку; для неё default выбирается явно. «Отмена» возвращает последний прочитанный конфиг.
Редактор не обращается к каталогу основной админки и не проверяет реализацию номера
на сайте: можно записать theme=3 или indpic=9 независимо от наличия темы/файлов.
Соответствие номера содержимому сайта обеспечивает пользователь. Формат номера,
дубликаты, aliases, проценты и CAS проверяются; конфиг не нормализуется автоматически.
Сохранение, копирование и восстановление одинаково независимы от каталога/наборов.
Новый сет загружается отдельно: выбрать домены, вручную указать номер, выбрать ZIP,
нажать «Загрузить сет». Для существующего номера нужно подтвердить замену.
Проверка ZIP и изображений сохраняется, как и проверка файлов, загруженных по SFTP.
Загрузка не меняет config.json. После неё пользователь сам добавляет вариант indpic,
заполняет вес/default и сохраняет настройки. Автоматической связи нет.
## Приватное состояние и публикация наборов
Резервы и journal локального транспорта хранятся в приватном state. Для exact bind mount
папка подготовки `.preland-admin-staging` создаётся внутри корня с0700; публичный веб-сервер
другого UID обязан получать403 и через внешний origin, и через прямой loopback Apache.
При наличии nginx-правила deny оно служит дополнительной защитой. Одной надежды на
`.htaccess` недостаточно: AllowOverride может быть выключен. Каталог активного `/N/` —0755,
файлы0644. SFTP использует приватную sibling-папку вне webroot.
Переключение каталогов имеет два rename и durable journal. При аварии upload-child
supervisor сразу пытается восстановить полный прежний/новый набор под тем же lock;
при полном перезапуске это делает следующая операция транспорта. Неподтверждённый
результат остаётся unknown. Это восстановление после прерывания, а не обещание отсутствия
краткого окна между двумя rename. При недоступном SSH восстановление нельзя подтвердить.
## Сборка, проверки и выпуск
```sh
docker build -t preland-admin:<git-sha> -f admin/Dockerfile admin
docker run --rm --entrypoint composer -v "$PWD:/work:ro" -w /work/admin preland-admin:<git-sha> test
```
Дополнительные Docker проверки: `tests/backend-sftp.Dockerfile` — настоящий SSH/SFTP,
`tests/backend-worker.php` — цикл с паузой65секунд, `tests/ui-*.cjs` — реальные browser
сценарии. Последние выполняются только на disposable целях; они изменяют и восстанавливают
настройки. `NODE_PATH=/e2e/node_modules`, `CHROMIUM_PATH` задаёт браузер имеющегося e2e-образа.
Для production подготовить отдельный TLS/nginx по `deploy/nginx.conf`, импортировать
проверенные подключения, сохранить резерв состояния, указать точный image в `.env`, затем
`docker compose up -d`. Проверить web/worker health, вход, четыре чтения, безопасный реальный
цикл сохранения/перечитывания, ZIP и публичные изображения. Не заменять runtime-конфиги
Git-снимком. Для отката приложения вернуть прежний `ADMIN_IMAGE`; state и контент сохраняются.
Возврат контента выполняется отдельно через резерв и CAS, не через старый Docker-образ.
Фактические production-команды, версии, ограничения, свидетельства UI и статус подключения
четырёх доменов фиксируются в preland#22 и KB; этот README сам не доказывает завершение выпуска.
## Browser проверки preland#25
`ui-matrix.cjs` выполняет полный цикл на четырёх disposable local-целях a/b/c/d:
inline input/default/add/remove/missing, Save/Cancel, параллельные черновики,
задержанный GET, настоящий CAS-конфликт и offline/recovery, copy двух получателей,
partial failure/source changed, aliases/unknown/requisites, независимые ZIP-цели,
ошибочный ZIP, конструктор/заметки и1440/390. Скрипту нужны writable mount fixture
в `/fixture` и output в `/output`; он возвращает исходные config-файлы в finally.
`ui-regression.cjs` — совместимая точка входа этого же сценария, отдельно повторять её
не требуется. `ui-utm.cjs` отдельно проверяет настоящую ZIP-доставку в a+b, variant3
с весом0/default и обратное восстановление через controls; нужен `VALID_ZIP` набора3.
`ui-notes.cjs`, `ui-link-builder.cjs`, `ui-verified-refresh.cjs`, `ui-remember.cjs`,
`ui-requisites.cjs` и `ui-smoke.cjs` сохраняют отдельные сценарии своих подсистем.
Ни один browser write-test не предназначен для production.
Отдельный `ui-session-jobs.cjs` удерживает ответ настоящего завершённого Save/Copy
через выход и повторный вход: старое продолжение не сбрасывает новый черновик,
не подменяет результаты/notice и не снимает busy новой операции. Серверные задания
при выходе продолжаются; отменяются только устаревшие UI-продолжения.
## Скрытие, удаление и порядок доменов (preland#26)
Значок перечёркнутого глаза справа от домена временно убирает его из таблицы и
рабочих списков. Подключение, секрет, проверка соединения, сайт, config.json и наборы
сохраняются. В «Скрытых доменах» доступны возврат и удаление подключения. Сервер
запрещает удалять видимый домен и домен с незавершённым заданием; сайт и файлы
удаление подключения не затрагивает. История сохраняется, результаты скрытых целей
не выводятся в рабочих списках.
Маркер слева от названия позволяет перетащить колонку мышью; стрелки на маркере
позволяют изменить порядок с клавиатуры. Порядок автоматически сохраняется на сервере
и применяется к таблице и спискам выбора для всех входов. Скрытый домен сохраняет
свою позицию, новый добавляется в конец. Черновики настроек и заметок при перестановке
сохраняются; перед скрытием изменённого домена нужно сохранить или отменить его правки.
API с обычными авторизацией и CSRF: `POST /api/targets/visibility` принимает
`{id,hidden}` (hidden — boolean), `POST /api/targets/order` принимает `{ids}` —
ровно все видимые ID в нужном порядке; оба возвращают актуальные `targets`.
Неверный/устаревший набор ID отвергается, скрытые позиции не перезаписываются.
`GET /api/state` содержит все подключения, включая hidden; интерфейс отдельно
фильтрует рабочие списки. Скрытие не отменяет уже запущенное задание.
Ответы API401 не отправляют Set-Cookie: задержанный ответ старого запроса не может
перезаписать cookie нового успешного входа. Истёкший сеанс отзывается на сервере,
а явный выход (200) удаляет cookie. UI также отбрасывает ответы прежнего сеанса.
Проверка `ui-target-login-race.cjs` включает следующий авторизованный запрос и
сохранение порядка после задержанного401, а не только видимость рабочей области.
## Номера вариантов и чтение с хостинга (#29)
В строках UTM редактируются только вес и default; номера отображаются текстом,
добавление/удаление вариантов и создание отсутствующей группы из таблицы убраны.
Копирование сохранённых групп остаётся доступно. API config возвращает read_at UTC
как метаданные успешного Transport-чтения, не записывая его в config.json.
Колонка показывает время последнего успешного чтения МСК с секундами, отдельные
loading/error/retry состояния и «Записано на хостинг» после подтверждённых Save/Copy.
Ошибка сохраняет прошлую дату; ввод во время чтения сохраняет черновик, но обновляет
метаданные чтения. Сохранение блокируется до завершения чтения.
## Процентный контракт (#28)
В `config.json` веса известных UTM хранятся непосредственно в процентах: числа
0–100 с точностью до двух знаков; сумма группы — 100 либо все значения равны 0.
Поле «Вес, %» показывает эти же числа, без пересчёта и перераспределения ввода.
Номер варианта и default не меняются при изменении веса. Промежуточная неверная
сумма допускается в черновике и блокирует сохранение с понятной причиной.
Config/Transport проверяют единый контракт для Save, Copy, Restore и проверки
нового подключения. Старые относительные конфиги и резервные копии не публикуются;
сами архивы сохраняются. Совместимость со старой единицей весов не поддерживается
по прямому решению владельца. Не путать это с неизменными alias/defaulttypes.
Публичный выбор варианта остаётся прежним: масштабирование до процентов сохраняет
доли; поведение полностью нулевой группы не заменяется новым fallback.
`PercentMigration` применяется только явно при миграции: заменяет числовые токены
известных weights, сохраняя остальные байты. Доли округляются независимо до сотых;
если сумма округлений не равна 100, операция останавливается до записи всех целей.
Остаток автоматически не распределяется. Приватный CLI с планом/receipt/CAS делает
резервные копии и проверяет точные bytes после каждой записи; в public его нет.
## Независимые UTM-наборы — preland#32
Дискета перед глазиком создаёт снимок текущих UTM-полей таблицы, включая ещё не опубликованные правки. Сохранение набора не пишет на хостинг. Обязательно собственное название; дубликат без учёта регистра отклоняется. Приватный utm-presets.json содержит только id/name/utm: ни домен, ни ID подключения, ни происхождение не сохраняются. Host, заметки, реквизиты и файлы сетов не входят в набор.
GET/POST/DELETE /api/utm-presets требуют вход; записи требуют CSRF. POST принимает только name и utm (канонические группы, только default/weights). DELETE принимает только id. Копирование POST /api/copy принимает preset и targets с ID/версией каждого получателя; прежний источник-домен больше не поддерживается. Очередь фиксирует собственный UTM-снимок, поэтому удаление набора или любого подключения-источника не влияет на задание. Получателем может быть любой доступный домен. Основной сайт и заметки сохраняются, отсутствующая группа набора не удаляет группу получателя. CAS, резерв и подтверждённое перечитывание сохранены.
Проверки: tests/utm-presets.php, обновлённый tests/copy.php и ui-utm-presets.cjs; helper preset-ui-helper.cjs адаптирует прежние copy-регрессии к независимому источнику. UI покрывает черновик без публикации, имена/дубликаты, два получателя, reload, гонку list GET/delete, 401/relogin, отмену удаления и API-удаление, мобильную ширину. Тестовые удаления выполняются только над созданными этим тестом наборами.
api#347: part — девятая UTM, пятый символ1/2, default1. Поддерживается редактором, именованными UTM-наборами, копированием, заметками и конструктором. Preland только передаёт метку, ссылка «Для бизнеса» там не добавляется. Runtime config автоматически не дополняется. Старые сохранённые ссылки получают независимый part1 без изменения остальных query-шумов; recovery использует общий приоритет сохранённого snapshot перед текущим resolved fallback.
+4
View File
@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
// Пароль читается только из stdin; не передавать его через аргументы командной строки.
$password=rtrim(stream_get_contents(STDIN),"\r\n");if(strlen($password)<12){fwrite(STDERR,"Пароль должен содержать не менее 12 символов.\n");exit(1);}echo password_hash($password,PASSWORD_DEFAULT)."\n";
+5
View File
@@ -0,0 +1,5 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
// Начальная настройка из закрытого stdin. Секреты никогда не выводятся.
try{$input=json_decode(stream_get_contents(STDIN),true,64,JSON_THROW_ON_ERROR);$store=new PrelandAdmin\Store();foreach($input as $item){$secret=$item['secret']??null;unset($item['secret']);$target=$store->saveTarget($item,$secret);$actual=$store->target($target['id']);(new PrelandAdmin\Transport($store,$actual))->test();$store->verifyTarget($target['id'],$actual);}echo "Подключения импортированы и проверены.\n";}catch(Throwable){fwrite(STDERR,"Импорт не выполнен. Проверьте приватные параметры и соединения.\n");exit(1);}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
$jobIndex=array_search('--job',$argv,true);
// Supervisor не создаёт Imagick: его policy time не должна накапливаться между заданиями.
if($jobIndex===false)define('PRELAND_ADMIN_SUPERVISOR',true);
require dirname(__DIR__).'/bootstrap.php';
$store=new PrelandAdmin\Store();$jobs=new PrelandAdmin\Jobs($store);
if($jobIndex!==false){$id=$argv[$jobIndex+1]??'';if(!preg_match('/^[a-f0-9]{32}$/D',$id))exit(2);$jobs->run($id);exit(0);}
$once=in_array('--once',$argv,true);
do {
try {
$id=$jobs->pending();
if($id!==null){
$process=proc_open([PHP_BINARY,__FILE__,'--job',$id],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes);
if(!is_resource($process))throw new RuntimeException('Не удалось запустить исполнитель.');fclose($pipes[0]);
$deadline=microtime(true)+max(30,min(900,(int)(getenv('ADMIN_JOB_TIMEOUT')?:300)));$exit=null;
do{$state=proc_get_status($process);if(!$state['running']){$exit=$state['exitcode'];break;}if(microtime(true)>$deadline){proc_terminate($process);usleep(250000);$state=proc_get_status($process);if($state['running'])proc_terminate($process,9);$exit=124;break;}usleep(100000);}while(true);
$closed=proc_close($process);if(($exit??$closed)!==0)$jobs->interrupted($id);
}
}catch(Throwable){fwrite(STDERR,"Не удалось обработать очередь. Повтор через 2 секунды.\n");}
if(!$once)sleep(2);
}while(!$once);
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
if (is_file(__DIR__ . '/vendor/autoload.php')) require __DIR__ . '/vendor/autoload.php';
spl_autoload_register(static function (string $class): void {
$prefix = 'PrelandAdmin\\';
if (str_starts_with($class, $prefix)) {
$path = __DIR__ . '/src/' . substr($class, strlen($prefix)) . '.php';
if (is_file($path)) require $path;
}
});
umask(0077);
if (!defined('PRELAND_ADMIN_SUPERVISOR') && class_exists(Imagick::class)) {
Imagick::setResourceLimit(Imagick::RESOURCETYPE_MEMORY, 134217728);
Imagick::setResourceLimit(Imagick::RESOURCETYPE_MAP, 268435456);
Imagick::setResourceLimit(Imagick::RESOURCETYPE_DISK, 536870912);
Imagick::setResourceLimit(Imagick::RESOURCETYPE_TIME, 60);
Imagick::setResourceLimit(Imagick::RESOURCETYPE_THREAD, 2);
}
+36
View File
@@ -0,0 +1,36 @@
services:
web:
image: ${ADMIN_IMAGE:?Укажите неизменяемую версию образа}
restart: unless-stopped
env_file: [./secret.env]
environment:
ADMIN_STATE_DIR: /var/lib/preland-admin
ADMIN_LOCAL_ROOTS: /targets/zoompixel.ru,/targets/zoomstage.online
volumes:
- ./state:/var/lib/preland-admin
- /srv/preland/domains/zoompixel.ru:/targets/zoompixel.ru
- /srv/preland/domains/zoomstage.online:/targets/zoomstage.online
ports: ["127.0.0.1:18084:80"]
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
cap_add: [CHOWN, SETGID, SETUID, NET_BIND_SERVICE]
healthcheck:
test: ["CMD", "php", "-r", "exit(@file_get_contents('http://127.0.0.1/') === false ? 1 : 0);"]
interval: 30s
timeout: 5s
retries: 3
worker:
image: ${ADMIN_IMAGE:?Укажите неизменяемую версию образа}
restart: unless-stopped
user: "997:987"
command: ["php", "/app/bin/worker.php"]
env_file: [./secret.env]
environment:
ADMIN_STATE_DIR: /var/lib/preland-admin
ADMIN_LOCAL_ROOTS: /targets/zoompixel.ru,/targets/zoomstage.online
volumes:
- ./state:/var/lib/preland-admin
- /srv/preland/domains/zoompixel.ru:/targets/zoompixel.ru
- /srv/preland/domains/zoomstage.online:/targets/zoomstage.online
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
+8
View File
@@ -0,0 +1,8 @@
{
"name": "zoomzilla/preland-admin",
"description": "Защищённое управление runtime прелендингов",
"license": "proprietary",
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "ext-zlib": "*", "phpseclib/phpseclib": "^3.0"},
"config": {"sort-packages": true},
"scripts": {"test": ["php tests/utm-presets.php", "php tests/manual-variants.php", "php tests/percent-weights.php", "php tests/percent-migration.php", "php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php", "php tests/part.php"], "test:worker": "php tests/backend-worker.php"}
}
+254
View File
@@ -0,0 +1,254 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "571e6007d448594fcee9719e8947dd65",
"packages": [
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
"shasum": ""
},
"require": {
"php": "^8"
},
"require-dev": {
"infection/infection": "^0",
"nikic/php-fuzzer": "^0",
"phpunit/phpunit": "^9|^10|^11",
"vimeo/psalm": "^4|^5|^6"
},
"type": "library",
"autoload": {
"psr-4": {
"ParagonIE\\ConstantTime\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com",
"role": "Maintainer"
},
{
"name": "Steve 'Sc00bz' Thomas",
"email": "steve@tobtu.com",
"homepage": "https://www.tobtu.com",
"role": "Original Developer"
}
],
"description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
"keywords": [
"base16",
"base32",
"base32_decode",
"base32_encode",
"base64",
"base64_decode",
"base64_encode",
"bin2hex",
"encoding",
"hex",
"hex2bin",
"rfc4648"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
"time": "2025-09-24T15:06:41+00:00"
},
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpseclib/phpseclib",
"version": "3.0.57",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.57"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2026-08-26T12:13:21+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.3",
"ext-json": "*",
"ext-zip": "*",
"ext-imagick": "*",
"ext-zlib": "*"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
}
+18
View File
@@ -0,0 +1,18 @@
<VirtualHost *:80>
ServerName admin.zoomstage.online
DocumentRoot /app/public
<Directory /app/public>
AllowOverride None
Options -Indexes
Require all granted
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</Directory>
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "same-origin"
Header always set X-Frame-Options "DENY"
ErrorLog /proc/self/fd/2
# Не логировать параметры запросов, тела форм и секреты.
CustomLog /proc/self/fd/1 "%h %t %m %U %>s %b"
</VirtualHost>
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
listen [::]:80;
server_name admin.zoomstage.online;
location ^~ /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name admin.zoomstage.online;
ssl_certificate /etc/letsencrypt/live/admin.zoomstage.online/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.zoomstage.online/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 130m;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass http://127.0.0.1:18084;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
}
}
+10
View File
@@ -0,0 +1,10 @@
expose_php=Off
display_errors=Off
log_errors=On
upload_max_filesize=128M
post_max_size=130M
max_execution_time=60
memory_limit=512M
session.cookie_httponly=1
session.cookie_samesite=Strict
session.use_strict_mode=1
+22
View File
@@ -0,0 +1,22 @@
/* Local Bootstrap 3 compatible surface; indpic table/controls ported from
* api/modules/cpanel/views/settings/index.php at 591a67cc8302 (preland#22). */
*{box-sizing:border-box}body{margin:0;color:#333;background:#f7f7f7;font:14px/1.42857143 "Helvetica Neue",Helvetica,Arial,sans-serif}.container{width:min(1170px,100%);padding:0 15px;margin:0 auto}.navbar{background:#222;color:#ddd;min-height:52px}.nav-inner{display:flex;gap:24px;align-items:center;min-height:52px}.nav-inner strong{font-size:18px}.nav-inner button{margin-left:auto}h1{font-size:30px;font-weight:500;margin:24px 0 20px}h2{font-size:24px;font-weight:500;margin:0 0 18px}h3{font-size:18px;font-weight:500}p{margin:0 0 12px}.panel{padding:20px;background:white;border:1px solid #ddd;border-radius:4px;margin:20px 0}.login-panel{max-width:420px;margin:60px auto}.login-panel label{display:block;margin:15px 0}.form-control{display:block;width:100%;min-height:34px;padding:6px 12px;font:inherit;line-height:1.42857143;color:#555;background:#fff;border:1px solid #ccc;border-radius:4px;box-shadow:inset 0 1px 1px #0001}input[type=checkbox]{margin:0 5px 0 0;accent-color:#337ab7}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:2px solid #337ab7;outline-offset:2px}button,input,select{font:inherit}.btn{display:inline-block;padding:6px 12px;border:1px solid transparent;border-radius:4px;font-weight:400;text-align:center;cursor:pointer;background:white;color:#333}.btn-default{border-color:#ccc}.btn-default:hover{background:#e6e6e6}.btn-primary{background:#337ab7;border-color:#2e6da4;color:white}.btn-danger{background:#d9534f;color:white}.btn-xs{padding:1px 5px;font-size:12px}.btn:disabled{opacity:.5;cursor:not-allowed}.toolbar,.target-options,.utm-inline-options,.utm-inline-option{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.toolbar{margin:12px 0}.grow{flex:1;min-width:200px}.target-options label{padding:8px 10px;border:1px solid #ddd;border-radius:4px}.text-muted{color:#707070}.alert{border:1px solid #ddd;border-radius:4px;padding:15px;margin:12px 0;white-space:pre-wrap;overflow-wrap:anywhere}.alert-danger{background:#f2dede;color:#a94442;border-color:#ebccd1}.alert-success{background:#dff0d8;color:#3c763d}.alert-warning{background:#fcf8e3;color:#8a6d3b}.alert-info{background:#d9edf7;color:#31708f}.tabs{display:flex;flex-wrap:wrap;gap:24px;border-bottom:1px solid #ddd;padding-bottom:12px}a,.utm-default-action,.utm-group-save{color:#337ab7}.utm-default-action,.utm-group-save{border:0;background:none;cursor:pointer;padding:0;text-decoration:underline}.utm-default-value{font-weight:bold;color:#3c763d}.table-responsive{overflow-x:auto;max-width:100%}.table{width:100%;border-collapse:collapse;margin-bottom:12px}.table th{text-align:left;background:#f5f5f5}.table td,.table th{padding:8px;vertical-align:middle;border:1px solid #ddd}.table-hover tbody tr:hover{background:#f5f5f5}.indpic-sets-table{table-layout:fixed;min-width:900px}.indpic-sets-table th:first-child{width:170px}.indpic-default-column{width:105px}.indpic-check-column{width:110px}.indpic-actions-column{width:100px}.indpic-file-control{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.indpic-file-control input{max-width:230px;min-width:0}.indpic-add-form{display:flex;align-items:end;flex-wrap:wrap;gap:10px;margin-top:14px}.indpic-number-input{width:96px}.indpic-add-form .checkbox{margin:0 4px 7px}.indpic-add-form input[name=name]{width:170px}.indpic-add-form input[type=file]{max-width:300px}.utm-group-number{font-size:18px;font-weight:600}.utm-group-name{margin:6px 0}.utm-inline-options{gap:24px;margin-bottom:12px}.utm-inline-option{gap:6px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.field{margin:12px 0}.field-head{display:flex;align-items:center;gap:6px;margin-bottom:6px}.field-head label{font-weight:600}.nested{border-left:3px solid #eee;padding-left:15px;min-width:0}.field-block{margin:18px 0;padding-top:12px;border-top:1px solid #eee}.weight-table{max-width:560px}.weight-table input,.weight-table select{width:100%;min-width:75px}.mixed{font-size:12px;color:#8a6d3b;margin-left:8px}.job{margin:16px 0;border-top:1px solid #ddd;padding-top:12px}.job .table{min-width:480px}.status-success{color:#3c763d}.status-error,.status-conflict{color:#a94442}.status-unknown{color:#8a6d3b}.connection{padding:10px 0;border-bottom:1px solid #eee;display:flex;align-items:center;gap:10px;flex-wrap:wrap}.connection span{flex:1;min-width:160px}.break{overflow-wrap:anywhere}dialog{border:1px solid #999;border-radius:6px;box-shadow:0 5px 15px #0008;padding:0;width:960px;max-width:calc(100% - 40px)}dialog::backdrop{background:#0007}.modal-header{position:relative;text-align:center;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header h2{font-size:18px;margin:0 34px}.close{position:absolute;right:15px;top:10px;background:none;border:0;font-size:25px;color:#777;cursor:pointer}.modal-body{padding:15px;max-height:calc(100vh - 180px);overflow-y:auto;white-space:pre-wrap;overflow-wrap:anywhere}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}[hidden]{display:none!important}@media(max-wLine truncated
#notice{position:sticky;top:8px;z-index:100;box-shadow:0 2px 6px #0002}
/* Overview and link builder retain the existing CPanel / Bootstrap surface. */
.overview-wrapper{margin-top:15px}.overview-table{min-width:760px;table-layout:fixed}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){min-width:190px}.overview-table th,.overview-table td{vertical-align:top;overflow-wrap:anywhere}.overview-table th:first-child,.overview-table td:first-child{position:sticky;left:0;background:#f5f5f5;z-index:1}.overview-table caption{text-align:left;padding:8px 0}.overview-domain{font-size:15px;margin-bottom:6px}.overview-status{font-size:12px;font-weight:400;margin:8px 0}.overview-weights{list-style:none;padding:0;margin:7px 0 0}.overview-weights li{margin:3px 0}.overview-warning{color:#8a6d3b;margin-top:8px;font-size:12px}.overview-error{color:#a94442}.overview-table .toolbar{gap:6px;margin-bottom:0}.builder-domain{display:block;max-width:450px;margin:15px 0}.builder-field{display:block}.builder-field select{margin-top:5px}#builder-fields{margin:15px 0}#builder-result{margin-top:18px}#builder-url{font-family:monospace}#builder-state{margin-top:10px}#builder-warnings .alert{padding:10px}#builder-save{margin-top:5px}@media(max-width:600px){.overview-table{min-width:720px}.overview-table th:first-child{width:90px}.overview-table .btn{padding:4px 8px}.builder-domain{max-width:100%}}
.overview-plain{white-space:pre-wrap}
.overview-default{font-weight:400}.overview-table tr[data-utm-note] th{font-size:16px}.overview-table tr[data-overview-utm] th{background:#fafafa}
.login-panel .remember-choice{display:flex;align-items:center;gap:6px;font-weight:400}.login-panel .remember-choice input{flex:0 0 auto;margin:0}
/* #23: compact numeric percentages; note rows span the whole matrix without sticky cells. */
.overview-percent{display:block;margin:6px 0;line-height:1.35}.overview-percent:first-child{margin-top:0}.overview-percent:last-child{margin-bottom:0}.overview-raw{font-size:12px;overflow-wrap:anywhere}#overview-table td.utm-note-cell{position:static;left:auto;width:auto;min-width:0;z-index:auto;background:#fafafa;white-space:normal}.utm-note-title{display:block;font-size:12px;color:#707070;margin-bottom:6px}.utm-note-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:0 0 8px}.utm-note-textarea{width:100%;max-width:100%;min-width:0;resize:vertical;min-height:90px}.utm-note-status{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.utm-note-status:empty{display:none}.utm-note-edit .toolbar{margin-bottom:0}.utm-note-cell .btn{white-space:normal}.utm-note-read:has(.utm-note-text[hidden]){display:flex;align-items:center}
/* #25: one editable matrix and independent saved-settings copy. */
.container{width:min(1440px,100%)}.overview-table{min-width:1280px}.overview-table th:first-child{width:110px}.overview-table th:not(:first-child){width:auto}.overview-table td{position:static!important}.overview-table tr[data-overview-utm] th{background:#fafafa}.overview-table .matrix-variant{display:grid;grid-template-columns:44px 76px 1fr;align-items:center;gap:6px;margin-top:7px}.matrix-variant .form-control{height:30px;min-height:30px;min-width:0;padding:3px 5px;font-size:13px}.matrix-labels{display:grid;grid-template-columns:44px 76px 1fr;gap:6px;color:#78818e;font-size:11px}.matrix-default{display:flex;gap:4px;align-items:center;font-size:12px;font-weight:400;white-space:nowrap}.matrix-default input{margin:0}.matrix-total,.matrix-validation{font-size:12px;font-weight:400;margin:7px 0 0}.matrix-remove{padding:0;border:0;background:none;color:#7a8594;font-size:19px;cursor:pointer;line-height:24px}.matrix-remove:hover{color:#a94442}.matrix-variants+.btn{font-size:12px;border:0;background:none;padding:6px 0 0;color:#337ab7}.matrix-footer td{background:#f8f9fb}.matrix-footer .toolbar{margin:0;gap:7px}.matrix-status{font-size:12px;font-weight:400;margin:7px 0}.matrix-dirty{color:#9c6413}.matrix-environment{font-size:12px;font-weight:400}.copy-grid{display:grid;grid-template-columns:320px minmax(0,1fr);gap:32px}.copy-grid>label{font-weight:600}.copy-grid select{margin-top:8px}.copy-hint{display:block;margin-top:10px;font-size:12px;font-weight:400}.copy-grid .target-options{margin-top:8px}.copy-bottom{display:flex;justify-content:space-between;align-items:center;gap:20px;border-top:1px solid #e1e5eb;margin-top:20px;padding-top:16px}.copy-bottom p{margin:0;font-size:13px}.copy-bottom .btn{flex-shrink:0}#copy-reason{margin:12px 0 0}.utm-note-read{display:flex;align-items:center;justify-content:space-between;gap:14px}.utm-note-text{margin:0}.utm-note-cell .btn{flex-shrink:0}.overview-wrapper{overscroll-behavior-x:contain}.overview-table caption{font-size:12px}@media(max-width:600px){.overview-table{min-width:1240px}.copy-grid{grid-template-columns:1fr;gap:20px}.copy-bottom{align-items:stretch;flex-direction:column}.copy-bottom .btn{align-self:flex-start}.overview-table th:first-child{width:90px}.matrix-footer .toolbar{flex-wrap:nowrap}.matrix-footer .btn{font-size:12px}.utm-note-read{align-items:flex-start}.utm-note-cell .btn{max-width:150px}.overview-table .form-control{font-size:13px}}
#notice{position:static;box-shadow:none}
.overview-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}.hidden-link{border:0;background:none;color:#52677c;text-decoration:underline;cursor:pointer;padding:6px 0}.domain-heading{display:flex;align-items:center;gap:7px}.domain-heading .overview-domain{margin:0;min-width:0;overflow-wrap:anywhere}.domain-grip,.domain-hide{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:0;background:transparent;color:#89949c;padding:3px;width:24px;height:24px;border-radius:4px}.domain-grip{cursor:grab;font-size:22px;touch-action:none}.domain-hide{cursor:pointer}.domain-grip:hover,.domain-hide:hover{background:#edf1f4;color:#40566b}.domain-hide:focus-visible,.domain-grip:focus-visible{outline:2px solid #337ab7}.domain-grip:disabled,.domain-hide:disabled{opacity:.4;cursor:wait}.hidden-domain-row{display:flex;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid #e2e7ec;flex-wrap:wrap}.domain-dragging{user-select:none;cursor:grabbing}.domain-drag-preview{position:fixed;z-index:1001;pointer-events:none;background:#fff;border:1px solid #9bb7cf;border-radius:6px;padding:12px;box-shadow:0 8px 24px #23394d30;opacity:.92}.domain-drag-preview>p,.domain-drag-preview>button{display:none}.domain-insertion{position:fixed;z-index:1000;pointer-events:none;width:3px;background:#337ab7}.hidden-domain-row .grow{min-width:180px}
.matrix-read-at{font-size:11px;font-weight:400;margin:4px 0 8px;line-height:1.4}.matrix-variant-number{font-size:13px;padding:3px 5px}
.matrix-add-variant{display:inline-flex;align-items:center;border:0;background:transparent;color:#337ab7;padding:8px 0 0;font-size:12px;cursor:pointer}.matrix-add-variant:hover{color:#23527c;text-decoration:underline}.matrix-add-variant:focus-visible{outline:2px solid #337ab7;outline-offset:3px}.matrix-add-variant:disabled{opacity:.5;cursor:default}.matrix-new-number{width:44px}.indpic-add-form{align-items:end}
.domain-preset{display:inline-flex;align-items:center;justify-content:center;padding:3px;border:0;background:transparent;color:#8092a2;border-radius:3px;cursor:pointer;flex-shrink:0}.domain-preset:hover{color:#337ab7;background:#e9f1f7}.domain-preset:disabled{opacity:.5;cursor:default}.preset-select-row{display:flex;gap:8px;align-items:stretch;margin-top:8px}.preset-select-row select{flex:1;min-width:0}.preset-icon{display:inline-flex;align-items:center;justify-content:center;padding:7px 9px;flex-shrink:0}.preset-icon:hover{color:#a94442}.preset-list-tools{margin:8px 0}.preset-dialog{width:460px}.preset-dialog .modal-body{white-space:normal}.preset-dialog label{display:block;font-weight:600;margin-bottom:8px}.preset-help{font-size:12px;margin:12px 0 0}.preset-dialog #preset-error:not(:empty){margin:12px 0 0}.preset-dialog .modal-footer{display:flex;justify-content:flex-end;gap:8px}
+368
View File
@@ -0,0 +1,368 @@
/* CPanel indpic UI adapted from api 591a67cc8302, settings/index.php and
* _utm-default.php. PublicUtmCodec labels retained; no runtime HTML injection. */
'use strict';
(() => {
const $ = id => document.getElementById(id);
const state = {sessionGeneration:0,csrf:'',allTargets:[],targets:[],targetsEpoch:0,targetsBusy:false,source:'',reads:new Map(),jobs:[],loading:0};
const markers=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
const enums={theme:{'1':'dark','2':'light'},reg:{'1':'before','2':'after'},banch:{'1':'b','2':'a'},focus:{'1':'face','2':'center'}};
const descriptions={theme:{'1':'тёмная','2':'светлая'},reg:{'1':'ввод имейл → подтверждение PIN → оплата','2':'ввод имейл → оплата → подтверждение PIN'},banch:{'1':'нет pop-up','2':'есть pop-up'},focus:{'1':'лицо, ближайшее к центру','2':'геометрический центр'},part:{'1':'показывать ссылку «Для бизнеса» на сайте','2':'скрывать ссылку «Для бизнеса» на сайте'}};
const formatDate=value=>{const date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{timeZone:'Europe/Moscow'})+' МСК';};
// Read-only runtime snapshots and independent constructor drafts never enter state.reads/fields.
const overview={entries:new Map(),pending:new Map(),serial:0};
const builder={target:'',entries:new Map(),serial:0};
function groupFor(config,marker){
const keys=[marker,...(marker==='reg'?['registration']:marker==='banch'?['chob','bchoice']:[])].filter(key=>Object.hasOwn(config?.utm||{},key));
return {key:keys[0],value:keys.length?config.utm[keys[0]]:null,conflict:keys.length>1?keys:[]};
}
function digitFor(marker,value){const raw=String(value??'');const semantic=Object.entries(enums[marker]||{}).find(([,name])=>name===raw)?.[0];const digit=semantic||raw;return /^[1-9]$/.test(digit)&&(marker!=='part'||['1','2'].includes(digit))?digit:null;}
const utmNotes={entries:new Map(),loading:false,error:'',serial:0};
function noteEntry(marker){
if(utmNotes.entries.has(marker)){const entry=utmNotes.entries.get(marker);entry.cell.colSpan=Math.max(1,state.targets.length);return entry;}
const entry={marker,epoch:0,note:null,draft:'',baseVersion:'',editing:false,saving:false,loading:false,error:'',conflict:false};
const makeButton=(text,action)=>el('button',{type:'button',class:'btn btn-default btn-xs',text,onclick:()=>run(action)});
entry.row=el('tr',{'data-utm-note':marker});entry.cell=el('td',{class:'utm-note-cell'});entry.row.append(el('th',{scope:'row',text:marker}),entry.cell);
entry.text=el('div',{class:'utm-note-text'});
entry.edit=makeButton('Добавить заметку',()=>{if(!entry.note)return;entry.epoch++;entry.draft=entry.note.text;entry.baseVersion=entry.note.version;entry.editing=true;entry.error='';entry.conflict=false;renderNote(entry);entry.textarea.focus();});
entry.edit.setAttribute('aria-label','Добавить заметку '+marker);
entry.readView=el('div',{class:'utm-note-read'},entry.text,entry.edit);
entry.textarea=el('textarea',{class:'form-control utm-note-textarea',rows:3,'aria-label':'Заметка '+marker});
entry.textarea.addEventListener('input',()=>{entry.draft=entry.textarea.value;if(!entry.conflict)entry.error='';renderNote(entry);});
entry.save=makeButton('Сохранить',()=>saveNote(marker));entry.save.setAttribute('aria-label','Сохранить заметку '+marker);
entry.cancel=makeButton('Отмена',()=>{const wasConflict=entry.conflict;entry.epoch++;entry.editing=false;entry.draft=entry.note?.text||'';entry.error='';entry.conflict=false;renderNote(entry);if(wasConflict)run(loadNotes);});entry.cancel.setAttribute('aria-label','Отменить заметку '+marker);
entry.reload=makeButton('Загрузить сохранённую заметку',()=>loadCurrentNote(marker));entry.reload.setAttribute('aria-label','Загрузить сохранённую заметку '+marker);
entry.editView=el('div',{class:'utm-note-edit'},entry.textarea,el('div',{class:'toolbar'},entry.save,entry.cancel,entry.reload));
entry.retry=makeButton('Повторить загрузку заметок',loadNotes);entry.retry.setAttribute('aria-label','Повторить загрузку заметок '+marker);
entry.status=el('p',{class:'utm-note-status text-muted',role:'status','aria-live':'polite'});
entry.cell.append(entry.readView,entry.editView,entry.status,entry.retry);utmNotes.entries.set(marker,entry);renderNote(entry);return entry;
}
function renderNote(entry){
entry.cell.colSpan=Math.max(1,state.targets.length);entry.text.textContent=entry.note?.text||'';entry.text.hidden=!entry.note?.text;
entry.readView.hidden=entry.editing;entry.editView.hidden=!entry.editing;
entry.edit.textContent=entry.note?.text?'Редактировать':'Добавить заметку';entry.edit.setAttribute('aria-label',(entry.note?.text?'Редактировать':'Добавить')+' заметку '+entry.marker);entry.edit.disabled=!entry.note||entry.loading;
if(entry.textarea.value!==entry.draft)entry.textarea.value=entry.draft;
entry.textarea.disabled=entry.saving||entry.loading;entry.save.disabled=entry.saving||entry.loading||entry.conflict||!entry.baseVersion;entry.cancel.disabled=entry.saving||entry.loading;
entry.reload.hidden=!entry.conflict;entry.reload.disabled=entry.saving||entry.loading;
entry.retry.hidden=!utmNotes.error;entry.retry.disabled=utmNotes.loading;
const error=entry.error||utmNotes.error;entry.status.className='utm-note-status '+(error?'overview-error':'text-muted');
entry.status.textContent=entry.saving?'Сохранение заметки…':entry.loading?'Загрузка сохранённой заметки…':error?error:utmNotes.loading?'Загрузка заметок…':entry.note?'': 'Заметка ещё не загружена.';
}
function validNote(note){return note&&typeof note.text==='string'&&typeof note.version==='string'&&(note.saved_at===null||typeof note.saved_at==='string');}
async function loadNotes(){
const request=++utmNotes.serial,epochs=new Map(markers.map(marker=>[marker,noteEntry(marker).epoch]));utmNotes.loading=true;utmNotes.error='';for(const entry of utmNotes.entries.values())renderNote(entry);
try{const result=await api('utm-notes');if(request!==utmNotes.serial||!state.csrf)return;for(const marker of markers){const entry=noteEntry(marker),note=result.notes?.[marker];if(entry.epoch!==epochs.get(marker)||entry.saving||entry.loading)continue;if(!validNote(note)){entry.error='Сервер не вернул корректную заметку '+marker+'.';continue;}if(!entry.editing){entry.note=note;entry.draft=note.text;entry.baseVersion=note.version;entry.error='';}else if(entry.baseVersion!==note.version){entry.error='Заметка уже изменена. Загрузите сохранённую заметку явно; черновик сохранён.';entry.conflict=true;}}}
catch(error){if(request===utmNotes.serial&&state.csrf)utmNotes.error='Ошибка загрузки заметок: '+error.message;}
finally{if(request===utmNotes.serial){utmNotes.loading=false;for(const entry of utmNotes.entries.values())renderNote(entry);}}
}
async function loadCurrentNote(marker){
const entry=noteEntry(marker);entry.epoch++;entry.loading=true;renderNote(entry);
try{const result=await api('utm-notes');if(utmNotes.entries.get(marker)!==entry||!state.csrf)return;const note=result.notes?.[marker];if(!validNote(note))throw new Error('Сервер не вернул корректную сохранённую заметку.');entry.epoch++;entry.note=note;entry.draft=note.text;entry.baseVersion=note.version;entry.conflict=false;entry.error='';}
catch(error){entry.error='Не удалось загрузить сохранённую заметку: '+error.message;}
finally{entry.loading=false;if(utmNotes.entries.get(marker)===entry)renderNote(entry);}
}
async function saveNote(marker){
const entry=noteEntry(marker);if(!entry.editing||entry.saving||entry.loading||entry.conflict||!entry.baseVersion)return;entry.epoch++;entry.saving=true;entry.error='';renderNote(entry);
try{const result=await api('utm-notes',{marker,text:entry.draft,version:entry.baseVersion});if(utmNotes.entries.get(marker)!==entry||!state.csrf)return;if(!validNote(result.note))throw new Error('Сервер не подтвердил сохранённую заметку.');entry.epoch++;entry.note=result.note;entry.draft=result.note.text;entry.baseVersion=result.note.version;entry.editing=false;entry.conflict=false;}
catch(error){entry.error=error.message;entry.conflict=error.status===409;}
finally{entry.saving=false;if(utmNotes.entries.get(marker)===entry)renderNote(entry);}
}
function overviewExtra(config){
const values=new Map(),secretKeys=new Set(['secret','password','private_key','privatekey','credential','credentials','api_key','apikey','access_token','accesstoken','refresh_token','refreshtoken','authorization']);
const visit=(path,value)=>{const key=path.join('.');if(path.some(part=>secretKeys.has(String(part).toLowerCase().replace(/-/g,'_')))){values.set(key,{label:key,text:'Скрытое поле'});return;}
if(value!==null&&typeof value==='object'){const entries=Object.entries(value);if(!entries.length){values.set(key,{label:key,text:Array.isArray(value)?'Пустой массив':'Пустой объект'});return;}for(const [child,next]of entries)visit([...path,child],next);return;}
const text=value===null?'null':typeof value==='boolean'?(value?'Да':'Нет')+' (boolean)':typeof value==='number'?String(value)+' (число)':String(value);
values.set(key,{label:key==='host'?'Основной сайт':key,text});
};
for(const [key,value]of Object.entries(config||{}))if(!['utm','requisites'].includes(key))visit([key],value);
const utm=config?.utm;
if(utm&&typeof utm==='object'&&!Array.isArray(utm)){for(const [key,value]of Object.entries(utm)){if(key==='chb')continue;
const marker=key==='registration'?'reg':['chob','bchoice'].includes(key)?'banch':key;
if(!markers.includes(marker))visit(['utm',key],value);
else if(groupFor(config,marker).conflict.length)visit(['utm',key],value);
else if(value&&typeof value==='object'&&!Array.isArray(value))for(const [extra,next]of Object.entries(value))if(!['default','weights'].includes(extra))visit(['utm',key,extra],next);
}}else if(utm!==undefined)visit(['utm'],utm);
return values;
}
const matrix={entries:new Map(),structure:'',copySource:'',copyTargets:new Set(),copyBusy:false,copyEpoch:0,copyError:'',copyResults:[],operationTargets:new Set()};
function matrixEntry(id){if(!matrix.entries.has(id))matrix.entries.set(id,{id,epoch:0,data:null,dirty:false,saving:false,error:'',conflict:false,cells:new Map(),groups:new Map()});return matrix.entries.get(id);}
function matrixDirty(){return [...matrix.entries.values()].some(e=>e.dirty||e.saving)||[...utmNotes.entries.values()].some(e=>e.editing||e.saving);}
function matrixChanged(entry){entry.epoch++;entry.dirty=true;entry.error='';entry.validation='';try{matrixChanges(entry);}catch(error){entry.validation=error.message;}updateMatrixStatus(entry);renderCopy();}
function updateMatrixStatus(entry){
if(!entry.statusNode)return;const reading=entry.reading;entry.statusNode.textContent=entry.saving?'Сохранение…':reading?'Чтение с хостинга…':entry.error||entry.validation|| (entry.dirty?'Есть изменения':entry.written?'Записано на хостинг':entry.data?'Прочитано с хостинга':'Чтение с хостинга…');if(entry.readAtNode)entry.readAtNode.textContent=entry.readAt?'Последнее чтение: '+new Intl.DateTimeFormat('ru-RU',{timeZone:'Europe/Moscow',day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(new Date(entry.readAt))+' МСК':'Последнее чтение: ещё не выполнено';entry.statusNode.className='matrix-status '+(entry.error||entry.validation?'overview-error':entry.dirty?'matrix-dirty':'text-muted');
entry.save.disabled=reading||!entry.dirty||!!entry.validation||entry.saving||entry.conflict||!entry.data||!state.targets.find(t=>t.id===entry.id)?.enabled||!state.targets.find(t=>t.id===entry.id)?.verified;entry.cancel.disabled=entry.saving||!entry.dirty;entry.reload.disabled=entry.saving||reading;entry.reload.textContent=entry.conflict?'Загрузить сохранённое':'Обновить';
if(entry.footerError){entry.footerError.textContent=entry.error||entry.validation||'';entry.footerError.hidden=!entry.footerError.textContent;}for(const cell of entry.cells.values())for(const input of cell.querySelectorAll('input,select,button'))input.disabled=entry.saving||input.dataset.exhausted==='true';
}
function matrixGroup(entry,marker,cell){
cell.replaceChildren();const group=groupFor(entry.data.config,marker),raw=group.value;
if(group.conflict.length){cell.append(el('p',{class:'overview-error',text:'Конфликт ключей: '+group.conflict.join(', ')}));return;}
if(group.key&&(!raw||typeof raw!=='object'||Array.isArray(raw)||!raw.weights||typeof raw.weights!=='object'||Array.isArray(raw.weights)||!digitFor(marker,raw.default)||Object.entries(raw.weights).some(([key,w])=>!digitFor(marker,key)||typeof w!=='number'||!Number.isFinite(w)||w<0)||new Set(Object.keys(raw.weights).map(k=>digitFor(marker,k))).size!==Object.keys(raw.weights).length)){cell.append(el('p',{class:'overview-error',text:'Некорректная группа. Редактирование недоступно.'}));return;}
const draft={key:group.key,original:structuredClone(raw||{weights:{}}),rows:[],defaultRow:null,added:false};entry.groups.set(marker,draft);renderGroup(draft);if(!group.key)return;for(const [key,w]of Object.entries(raw.weights))addVariant(draft,key,w);const defaultDigit=digitFor(marker,raw.default);if(!draft.rows.some(row=>digitFor(marker,row.variant.value)===defaultDigit))addVariant(draft,String(raw.default),0);draft.defaultRow=draft.rows.find(row=>digitFor(marker,row.variant.value)===defaultDigit);draft.defaultRow.radio.checked=true;percentages(draft);
function renderGroup(draft){draft.marker=marker;cell.replaceChildren();draft.list=el('div',{class:'matrix-variants'});draft.total=el('p',{class:'matrix-total text-muted','aria-live':'polite'});draft.validation=el('p',{class:'matrix-validation overview-error','aria-live':'polite'});cell.append(el('div',{class:'matrix-labels','aria-hidden':'true'},el('span',{text:'Вариант'}),el('span',{text:'Вес, %'}),el('span',{text:'Default'})),draft.list,el('button',{type:'button',class:'matrix-add-variant',text:'+ Добавить вариант','aria-label':'Добавить вариант '+marker+' '+entry.id,title:'Добавить вариант',onclick:()=>{addVariant(draft,'','',true);draft.added=!group.key;draft.touched=true;matrixChanged(entry);draft.rows.at(-1).variant.focus();}}),draft.total,draft.validation);}
function addVariant(draft,key,weight,isNew=false){const wrapper=el('div',{class:'matrix-variant'}),variant=isNew?el('input',{type:'text',inputMode:'numeric',maxLength:1,class:'form-control matrix-new-number',value:'','aria-label':'Новый вариант '+marker+' '+entry.id}):{value:key};const input=numberInput(weight,0);input.max='100';input.step='0.01';input.setAttribute('aria-label',marker+' вес '+key+' '+entry.id);const radio=el('input',{type:'radio',name:'default-'+entry.id+'-'+marker,'aria-label':marker+' default '+key+' '+entry.id});const row={wrapper,variant,weight:input,radio,isNew};draft.rows.push(row);wrapper.append(isNew?variant:el('span',{class:'matrix-variant-number',text:digitFor(marker,key)}),input,el('label',{class:'matrix-default'},radio,el('span',{text:'default'})));draft.list.append(wrapper);if(isNew){const label=()=>{input.setAttribute('aria-label',marker+' вес '+(variant.value||'новый')+' '+entry.id);radio.setAttribute('aria-label',marker+' default '+(variant.value||'новый')+' '+entry.id);};label();variant.addEventListener('input',()=>{label();draft.touched=true;matrixChanged(entry);});}input.addEventListener('input',()=>{percentages(draft);draft.touched=true;matrixChanged(entry);});radio.addEventListener('change',()=>{draft.defaultRow=row;draft.touched=true;matrixChanged(entry);});percentages(draft);}
}
function percentError(group){let sum=0;for(const row of group.rows){const text=row.weight.value,w=Number(text);if(text===''||!Number.isFinite(w)||w<0||w>100)return group.marker+': вес должен быть числом от 0 до 100%.';if(w!==Number(w.toFixed(2)))return group.marker+': в весе допустимо не более двух знаков после запятой.';sum+=Math.round(w*100);}return sum!==0&&sum!==10000?group.marker+': сумма весов должна быть 100% или все веса должны быть 0%.':'';}
function percentages(group){const values=group.rows.map(row=>row.weight.value===''?NaN:Number(row.weight.value)),sum=values.reduce((total,w)=>total+w,0);group.total.textContent='Сумма: '+(Number.isFinite(sum)?String(Math.round(sum*1000000)/1000000)+'%':'—');group.validation.textContent=percentError(group);group.validation.hidden=!group.validation.textContent;}
function matrixValidation(entry){for(const marker of markers){const group=groupFor(entry.data?.config,marker);if(group.key&&!entry.groups.has(marker))return marker+': некорректная группа. Обновите конфигурацию.';const draft=entry.groups.get(marker);if(draft){if(draft.rows.some(row=>!digitFor(marker,row.variant.value)))return marker+(marker==='part'?': укажите вариант 1 или 2.':': укажите номер варианта от 1 до 9.');if(new Set(draft.rows.map(row=>digitFor(marker,row.variant.value))).size!==draft.rows.length)return marker+': варианты должны быть уникальны.';const error=percentError(draft);if(error)return error;if(draft.touched&&!draft.defaultRow)return marker+': выберите default.';}}return '';}
function matrixChanges(entry){const invalid=matrixValidation(entry);if(invalid)throw Error(invalid);const changes=[];if(entry.hostTouched&&entry.host&&entry.host.value!==entry.data.config.host){if(!/^(?:https:\/\/)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/i.test(entry.host.value)||entry.host.value.includes('..'))throw Error('Адрес сайта должен быть HTTPS-хостом без пути.');changes.push({path:['host'],value:entry.host.value});}for(const [marker,group]of entry.groups){if(!group.touched&&!group.added)continue;if(!group.rows.length)throw Error(marker+': добавьте хотя бы один вариант.');const weights={};for(const row of group.rows){const key=row.variant.value,w=Number(row.weight.value);if(!row.isNew&&!Object.hasOwn(group.original.weights,key)&&w===0)continue;if(!digitFor(marker,key)||Object.hasOwn(weights,key))throw Error(marker+': варианты должны быть уникальны.');if(row.weight.value===''||!Number.isFinite(w)||w<0)throw Error(marker+': вес должен быть неотрицательным числом.');weights[key]=w;}if(!group.defaultRow)throw Error(marker+': выберите default.');const digit=digitFor(marker,group.defaultRow.variant.value),original=group.original;const rawDefault=original&&digitFor(marker,original.default)===digit?original.default:typeof original?.default==='number'?Number(digit):Object.values(enums[marker]||{}).includes(String(original?.default))?(enums[marker][digit]||digit):digit;if(group.added){changes.push({path:['utm',marker],value:{default:rawDefault,weights}});}else{if(!equivalent(weights,original.weights))changes.push({path:['utm',group.key,'weights'],value:weights});if(!equivalent(rawDefault,original.default))changes.push({path:['utm',group.key,'default'],value:rawDefault});}}return changes;}
function populateMatrix(entry){entry.groups.clear();entry.hostTouched=false;entry.validation='';for(const [key,cell]of entry.cells){cell.replaceChildren();if(key==='host'){entry.host=el('input',{class:'form-control',value:entry.data.config.host??'','data-host':'','aria-label':'Основной сайт '+entry.id});entry.host.addEventListener('input',()=>{entry.hostTouched=true;matrixChanged(entry);});cell.append(entry.host);}else matrixGroup(entry,key,cell);}entry.validation=matrixValidation(entry);updateMatrixStatus(entry);}
function renderOverview(){
const focused=document.activeElement,selection=focused&&'selectionStart'in focused?{start:focused.selectionStart,end:focused.selectionEnd}:null;const signature=state.targets.map(t=>t.id).join('|'),table=$('overview-table');table.style.minWidth=Math.max(760,110+state.targets.length*292)+'px';if(matrix.structure!==signature&&matrix.structure.split('|').slice().sort().join('|')===signature.split('|').slice().sort().join('|')&&table.querySelector('thead tr')){const ids=state.targets.map(t=>t.id);for(const row of table.querySelectorAll('tr')){if(row.hasAttribute('data-utm-note'))continue;const children=[...row.children];if(children.length===ids.length+1)for(const id of ids){const oldIndex=matrix.structure.split('|').indexOf(id);row.append(children[oldIndex+1]);}}matrix.structure=signature;if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);}}if(matrix.structure!==signature||!table.querySelector('thead tr')){matrix.structure=signature;const head=el('tr',{},el('th',{scope:'col',text:'Параметр'})),body=table.querySelector('tbody');body.replaceChildren();table.querySelector('thead').replaceChildren(head);const hostrow=el('tr',{'data-overview-extra':'host'},el('th',{scope:'row',text:'Основной сайт'}));body.append(hostrow);for(const target of state.targets){const entry=matrixEntry(target.id);entry.statusNode=el('p',{class:'matrix-status',role:'status'});entry.readAtNode=el('p',{class:'matrix-read-at text-muted'});entry.reload=button('Обновить',()=>{if(entry.dirty&&!confirm('Загрузить сохранённые настройки и сбросить черновик '+target.domain+'?'))return;return refreshOverviewTarget(target.id,true);});entry.reload.setAttribute('aria-label','Обновить настройки '+target.id);head.append(el('th',{'data-overview-target':target.id},domainHeading(target),el('div',{class:'text-muted matrix-environment',text:target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено')}),entry.statusNode,entry.readAtNode,entry.reload));const cell=entry.cells.get('host')||el('td',{'data-matrix-target':target.id});entry.cells.set('host',cell);hostrow.append(cell);}
for(const marker of markers){const row=el('tr',{'data-overview-utm':marker},el('th',{'aria-hidden':'true'}));for(const target of state.targets){const cell=matrixEntry(target.id).cells.get(marker)||el('td',{'data-matrix-target':target.id,'data-marker':marker});matrixEntry(target.id).cells.set(marker,cell);row.append(cell);}body.append(noteEntry(marker).row,row);}
const footer=el('tr',{class:'matrix-footer'},el('th',{scope:'row',text:'Сохранение'}));for(const target of state.targets){const entry=matrixEntry(target.id);entry.save=button('Сохранить',()=>saveMatrix(target.id),'primary');entry.save.setAttribute('aria-label','Сохранить настройки '+target.id);entry.cancel=button('Отмена',()=>{if(entry.conflict)return refreshOverviewTarget(target.id,true);entry.epoch++;entry.dirty=false;entry.error='';entry.validation='';populateMatrix(entry);renderCopy();});entry.cancel.setAttribute('aria-label','Отменить настройки '+target.id);entry.footerError=el('p',{class:'overview-error matrix-footer-error',role:'alert','aria-live':'polite',hidden:true});footer.append(el('td',{},el('div',{class:'toolbar'},entry.save,entry.cancel),entry.footerError));if(entry.data&&!entry.host)populateMatrix(entry);else updateMatrixStatus(entry);}body.append(footer);if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);}
}
for(const target of state.targets){const entry=matrixEntry(target.id),snapshot=overview.entries.get(target.id);const title=entry.statusNode?.parentElement;if(title){title.querySelector('.overview-domain').textContent=target.domain;title.querySelector('.matrix-environment').textContent=target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено');}if(snapshot?.data&&entry.data!==snapshot.data&&!entry.dirty&&!entry.saving){entry.data=snapshot.data;populateMatrix(entry);}if(snapshot?.status==='error'&&!entry.error){entry.error='Ошибка чтения: '+snapshot.error;}updateMatrixStatus(entry);}renderTargetControls();renderExtras();renderCopy();if(builder.target&&builder.entries.get(builder.target)?.ready)updateBuilderState();
}
function renderExtras(){const body=$('overview-table').querySelector('tbody'),values=new Map(),keys=new Map();for(const t of state.targets){const v=overviewExtra(matrixEntry(t.id).data?.config);v.delete('host');values.set(t.id,v);for(const [key,item]of v)keys.set(key,item.label);}body.querySelectorAll('[data-matrix-extra]').forEach(n=>n.remove());for(const [key,label]of keys){const row=el('tr',{'data-matrix-extra':key},el('th',{text:label}));for(const t of state.targets)row.append(el('td',{class:'overview-plain',text:values.get(t.id).get(key)?.text||'Поле отсутствует'}));body.append(row);}}
function refreshOverviewTarget(id,force=false){if(!state.targets.some(t=>t.id===id))return Promise.resolve();const entry=matrixEntry(id);if(entry.reading)return overview.pending.get(id)||Promise.resolve();const epoch=++entry.epoch,request=++overview.serial,previous=overview.entries.get(id);entry.reading=true;overview.entries.set(id,{...previous,status:'loading',request});renderOverview();const pending=(async()=>{try{const data=await api('config?target='+encodeURIComponent(id));if(matrix.entries.get(id)!==entry||!state.csrf)return;entry.readAt=data.read_at||entry.readAt;entry.written=false;entry.error='';overview.entries.set(id,{status:'success',request,data});if(entry.epoch!==epoch)return;if(entry.dirty&&!force){if(entry.data?.version!==data.version){entry.conflict=true;entry.error='Настройки изменены на сервере. Черновик сохранён. Загрузите сохранённое или отмените изменения.';}}else{entry.data=data;entry.dirty=false;entry.conflict=false;entry.error='';populateMatrix(entry);}}catch(error){if(matrix.entries.get(id)!==entry||!state.csrf)return;overview.entries.set(id,{...previous,status:'error',request,error:error.message});entry.error=error.message;}finally{if(matrix.entries.get(id)===entry){entry.reading=false;overview.pending.delete(id);renderOverview();}}})();overview.pending.set(id,pending);return pending;}
async function refreshOverview(){const control=$('overview-refresh');control.disabled=true;run(loadNotes);try{await Promise.allSettled(state.targets.map(t=>refreshOverviewTarget(t.id)));}finally{control.disabled=false;}}
async function waitMatrixJob(job,isCurrent){
let current=typeof job==='object'?job:null,id=typeof job==='string'?job:job.id;
while(!current||current.status!=='completed'){
await new Promise(resolve=>setTimeout(resolve,600));if(!isCurrent())return null;
current=(await api('jobs?id='+encodeURIComponent(id))).job;if(!isCurrent())return null;
}
if(!isCurrent())return null;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(current);renderJobs();return current;
}
async function saveMatrix(id){
const entry=matrixEntry(id),generation=state.sessionGeneration,isCurrent=()=>generation===state.sessionGeneration&&!!state.csrf&&matrix.entries.get(id)===entry;
if(entry.reading||entry.saving||entry.conflict||!entry.dirty)return;
try{
const changes=matrixChanges(entry);if(!changes.length){entry.dirty=false;updateMatrixStatus(entry);renderCopy();return;}
entry.epoch++;entry.saving=true;entry.error='';updateMatrixStatus(entry);renderCopy();
const result=await api('apply',{targets:[{id,version:entry.data.version}],changes});if(!isCurrent())return;
const job=await waitMatrixJob(result.job,isCurrent);if(!isCurrent()||!job)return;
const outcome=job.results?.find(r=>(typeof r.target==='object'?r.target.id:r.target)===id);
if(outcome?.status!=='success'){entry.conflict=outcome?.status==='conflict';throw Error(outcome?.message||'Сохранение не подтверждено. Проверьте журнал.');}
entry.saving=false;if(!isCurrent())return;await refreshOverviewTarget(id,true);if(!isCurrent())return;if(overview.entries.get(id)?.status==='success'){entry.written=true;updateMatrixStatus(entry);}
if(state.source===id){await fetchRead(id);if(!isCurrent())return;await loadSets();if(!isCurrent())return;await loadBackups();}
}catch(error){if(isCurrent()){entry.error=error.message;entry.conflict=entry.conflict||error.status===409;}}
finally{if(isCurrent()){entry.saving=false;updateMatrixStatus(entry);renderCopy();}}
}
const presets={items:[],loaded:false,loading:false,error:'',serial:0,busy:false,snapshot:null};
async function loadPresets(){
const serial=++presets.serial,generation=state.sessionGeneration;presets.loading=true;presets.error='';renderCopy();
try{const result=await api('utm-presets');if(serial!==presets.serial||generation!==state.sessionGeneration||!state.csrf)return;presets.items=result.presets;presets.loaded=true;if(!presets.items.some(p=>p.id===matrix.copySource))matrix.copySource='';}
catch(error){if(serial===presets.serial&&generation===state.sessionGeneration)presets.error=error.message;}
finally{if(serial===presets.serial&&generation===state.sessionGeneration){presets.loading=false;renderCopy();}}
}
function currentUtm(entry){
if(!entry.data||entry.reading||entry.saving)throw Error('Дождитесь чтения настроек и завершения сохранения.');
const invalid=matrixValidation(entry);if(invalid)throw Error(invalid);
const utm={};
for(const marker of markers){const group=entry.groups.get(marker),raw=groupFor(entry.data.config,marker);if(!raw.key&&!group?.added)continue;
if(!group)throw Error(marker+': некорректная группа.');
if(!group.touched&&!group.added){utm[marker]={default:structuredClone(raw.value.default),weights:structuredClone(raw.value.weights)};continue;}
if(!group.rows.length||!group.defaultRow)throw Error(marker+': заполните варианты и выберите default.');
const weights={};for(const row of group.rows){if(!row.isNew&&!Object.hasOwn(group.original.weights,row.variant.value)&&Number(row.weight.value)===0)continue;weights[row.variant.value]=Number(row.weight.value);}
utm[marker]={default:group.defaultRow.variant.value,weights};
}
if(!Object.keys(utm).length)throw Error('Нет UTM для сохранения.');return utm;
}
function openPreset(id){
try{presets.snapshot=currentUtm(matrixEntry(id));$('preset-name').value='';$('preset-name').disabled=false;$('preset-save').disabled=false;$('preset-cancel').disabled=false;$('preset-error').textContent='';$('preset-dialog').showModal();$('preset-name').focus();}
catch(error){notice(error.message);}
}
async function savePreset(event){
event.preventDefault();if(presets.busy||!presets.snapshot)return;const generation=state.sessionGeneration;
presets.busy=true;$('preset-save').disabled=true;$('preset-cancel').disabled=true;$('preset-name').disabled=true;$('preset-error').textContent='';
try{const result=await api('utm-presets',{name:$('preset-name').value,utm:presets.snapshot});if(generation!==state.sessionGeneration||!state.csrf)return;
presets.serial++;presets.loading=false;presets.loaded=true;presets.error='';presets.items=presets.items.filter(p=>p.id!==result.preset.id);presets.items.push({id:result.preset.id,name:result.preset.name});matrix.copySource=result.preset.id;
$('preset-dialog').close();presets.snapshot=null;notice('UTM-набор «'+result.preset.name+'» сохранён в админке. Настройки сайтов не изменены.','success');renderCopy();
}catch(error){if(generation===state.sessionGeneration)$('preset-error').textContent=error.message;}
finally{if(generation===state.sessionGeneration){presets.busy=false;$('preset-save').disabled=false;$('preset-cancel').disabled=false;$('preset-name').disabled=false;}}
}
async function deletePreset(){
const selected=presets.items.find(p=>p.id===matrix.copySource);if(!selected||presets.busy||presets.loading||matrix.copyBusy)return;
if(!confirm('Удалить набор «'+selected.name+'» из админки? Настройки сайтов и заметки не изменятся.'))return;
const generation=state.sessionGeneration;presets.busy=true;renderCopy();
try{await api('utm-presets',{id:selected.id},'DELETE');if(generation!==state.sessionGeneration||!state.csrf)return;presets.serial++;presets.items=presets.items.filter(p=>p.id!==selected.id);if(matrix.copySource===selected.id)matrix.copySource='';presets.error='';notice('Набор удалён.','success');}
catch(error){if(generation===state.sessionGeneration)presets.error=error.message;}
finally{if(generation===state.sessionGeneration){presets.busy=false;renderCopy();}}
}
function renderCopy(){
$('copy-all').disabled=matrix.copyBusy;$('copy-none').disabled=matrix.copyBusy;
const resultBox=$('copy-results');resultBox.replaceChildren();if(matrix.copyError)resultBox.append(el('p',{class:'overview-error',text:matrix.copyError}));for(const result of orderedResults(matrix.copyResults)){const id=typeof result.target==='object'?result.target.id:result.target;resultBox.append(el('p',{class:'status-'+result.status,text:targetName(id)+' — '+(statuses[result.status]||result.status)+(result.message?' · '+result.message:'')}));}
const source=$('copy-source');source.replaceChildren(el('option',{value:'',text:'Выберите сохранённый набор'}),...presets.items.map(p=>el('option',{value:p.id,text:p.name})));source.value=matrix.copySource;source.disabled=matrix.copyBusy||presets.busy||presets.loading;
$('preset-delete').disabled=!presets.items.some(p=>p.id===matrix.copySource)||matrix.copyBusy||presets.busy||presets.loading;
$('preset-reload').disabled=presets.loading||presets.busy||matrix.copyBusy;
$('preset-list-status').textContent=presets.loading?'Загрузка наборов…':presets.error?'Не удалось обновить список: '+presets.error:presets.loaded&&!presets.items.length?'Пока нет сохранённых наборов. Нажмите дискету рядом с доменом.':'';
const box=$('copy-targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',checked:matrix.copyTargets.has(target.id),disabled:matrix.copyBusy||!target.enabled||!target.verified,'aria-label':'Копировать в '+target.domain});input.addEventListener('change',()=>{if(input.checked)matrix.copyTargets.add(target.id);else matrix.copyTargets.delete(target.id);renderCopy();});box.append(el('label',{},input,target.domain));}
const targets=[...matrix.copyTargets].map(id=>matrixEntry(id));let reason=matrix.copyBusy?'Копирование…':presets.loading?'Дождитесь загрузки наборов.':presets.error?'Обновите список сохранённых наборов.':!presets.items.some(p=>p.id===matrix.copySource)?'Выберите сохранённый набор.':!targets.length?'Выберите домены получателей.':targets.some(e=>!state.targets.find(t=>t.id===e.id)?.enabled||!state.targets.find(t=>t.id===e.id)?.verified)?'Получатель отключён или соединение не проверено.':targets.some(e=>e.data&&matrixValidation(e))?'Получатель: '+matrixValidation(targets.find(e=>e.data&&matrixValidation(e))):targets.some(e=>e.dirty||e.saving)?'Сначала сохраните или отмените изменения получателей.':targets.some(e=>!e.data||e.conflict||overview.entries.get(e.id)?.status!=='success')?'Обновите настройки получателей.':'';
$('copy-reason').textContent=reason;$('copy-submit').disabled=!!reason||presets.busy;$('copy-submit').textContent='Копировать в '+targets.length+' '+(targets.length%10===1&&targets.length%100!==11?'домен':targets.length%10>=2&&targets.length%10<=4&&(targets.length%100<12||targets.length%100>14)?'домена':'доменов');
}
async function copySettings(){
if($('copy-submit').disabled)return;
const preset=matrix.copySource,ids=[...matrix.copyTargets],generation=state.sessionGeneration,operation=++matrix.copyEpoch;
const entries=new Map(ids.map(id=>[id,matrix.entries.get(id)]));
const sameOperation=()=>generation===state.sessionGeneration&&!!state.csrf&&matrix.copyEpoch===operation;
const isCurrent=()=>sameOperation()&&[...entries].every(([id,entry])=>matrix.entries.get(id)===entry);
matrix.copyError='';matrix.copyResults=[];matrix.copyBusy=true;for(const entry of entries.values()){entry.epoch++;entry.saving=true;updateMatrixStatus(entry);}renderCopy();
try{
const result=await api('copy',{preset,targets:ids.map(id=>({id,version:entries.get(id).data.version}))});if(!isCurrent())return;
const job=await waitMatrixJob(result.job,isCurrent);if(!isCurrent()||!job)return;matrix.copyResults=job.results||[];
for(const outcome of job.results||[]){if(!isCurrent())return;const id=typeof outcome.target==='object'?outcome.target.id:outcome.target,entry=entries.get(id);if(!entry)continue;
if(outcome.status==='success'){entry.saving=false;await refreshOverviewTarget(id);if(!isCurrent())return;if(overview.entries.get(id)?.status==='success')entry.written=true;}
else{entry.error=outcome.message||'Не удалось скопировать настройки.';entry.conflict=outcome.status==='conflict';updateMatrixStatus(entry);}}
if(!isCurrent())return;notice('Копирование завершено. Результат каждого домена показан в журнале.','info');
if(state.source&&ids.includes(state.source)){await fetchRead(state.source);if(!isCurrent())return;await loadSets();if(!isCurrent())return;await loadBackups();}
}catch(error){if(isCurrent()){matrix.copyError=error.message;notice(error.message);if(error.status===404)run(loadPresets);}}
finally{if(sameOperation()){matrix.copyBusy=false;for(const [id,entry]of entries)if(matrix.entries.get(id)===entry){entry.saving=false;updateMatrixStatus(entry);}renderCopy();}}
}
function syncOverview(refresh=true){
const ids=new Set(state.targets.map(target=>target.id));for(const id of matrix.entries.keys())if(!ids.has(id)){matrix.entries.get(id).epoch++;matrix.entries.delete(id);matrix.copyTargets.delete(id);matrix.operationTargets.delete(id);}for(const id of overview.entries.keys())if(!ids.has(id)){overview.entries.delete(id);overview.pending.delete(id);builder.entries.delete(id);}
if(builder.target&&!ids.has(builder.target))builder.target='';
const select=$('builder-target');select.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));select.value=builder.target;renderOverview();if(builder.target&&builder.entries.get(builder.target)?.domain!==state.targets.find(t=>t.id===builder.target)?.domain)run(()=>openBuilder(builder.target,true));else renderBuilder();if(refresh)run(refreshOverview);
}
function selectionsEqual(a,b){return !!a&&!!b&&markers.every(marker=>String(a[marker])===String(b[marker]));}
function constructorValid(selection){return markers.every(marker=>selection[marker]==='rnd'||!!digitFor(marker,selection[marker])&&String(digitFor(marker,selection[marker]))===selection[marker]);}
function builderWarnings(entry){
const result=[],snapshot=overview.entries.get(builder.target),config=snapshot?.data?.config;
if(!config){result.push('Текущий runtime-конфиг не прочитан; фактическое применение выбранных значений не проверено.');return result;}
if(snapshot.status!=='success')result.push('Проверка выбора использует прежний снимок runtime-конфига; обновите сводку.');
for(const marker of markers){const choice=entry.draft[marker],group=groupFor(config,marker);if(group.conflict.length){result.push(marker+': конфликт ключей '+group.conflict.join(', ')+'. Публичный resolver может выбрать другое значение.');continue;}
if(choice!=='rnd'&&!digitFor(marker,choice)){result.push(marker+': выберите допустимое значение. Сохранённый выбор не заменён автоматически.');continue;}
const weights=group.value?.weights||{},variants=Object.keys(weights).map(v=>digitFor(marker,v));
if(['indpic','degr','tp'].includes(marker)&&choice!=='rnd'&&!variants.includes(choice))result.push(marker+'='+choice+': варианта нет в текущих весах; при открытии ссылки resolver использует default (или штатный fallback). Конфигурация не меняется.');
if(!group.key)result.push(marker+': группа отсутствует в runtime; при открытии применяется штатный fallback.');
}
return result;
}
function renderBuilder(){
const entry=builder.entries.get(builder.target);$('builder-fields').replaceChildren();$('builder-result').hidden=true;$('builder-warnings').replaceChildren();$('builder-save').disabled=true;
if(!builder.target){$('builder-state').textContent='Выберите домен.';return;}
if(!entry?.ready){$('builder-state').textContent=entry?.error?'Ошибка чтения выбора: '+entry.error:'Чтение последнего выбора…';if(entry?.error)$('builder-fields').append(button('Повторить чтение выбора',()=>openBuilder(builder.target,true)));return;}
for(const marker of markers){const values=[...(marker==='part'?['1','2']:Array.from({length:9},(_,i)=>String(i+1))),'rnd'];const select=el('select',{class:'form-control','data-marker':marker,'aria-label':'Ссылка: '+marker},el('option',{value:'',text:'Выберите значение'}),...values.map(value=>el('option',{value,text:value==='rnd'?'Рандом':optionLabel(marker,value)})));
if(entry.draft[marker]&&!values.includes(entry.draft[marker]))select.append(el('option',{value:entry.draft[marker],text:'Недопустимый сохранённый выбор: '+entry.draft[marker]}));select.value=entry.draft[marker]||'';select.disabled=entry.saving;
select.addEventListener('change',()=>{entry.draft[marker]=select.value;entry.error='';updateBuilderState();});$('builder-fields').append(labelControl(marker,select));
}
updateBuilderState();
}
function updateBuilderState(){
const entry=builder.entries.get(builder.target);if(!entry?.ready)return;const dirty=!selectionsEqual(entry.draft,entry.saved),valid=constructorValid(entry.draft);
$('builder-state').textContent=entry.saving?'Формирование и сохранение…':entry.error?'Ошибка: '+entry.error:dirty?'Есть несохранённый выбор. Нажмите «Сформировать и сохранить».':'Показан последний сохранённый выбор этого домена.';
$('builder-save').disabled=!!entry.saving||!valid;for(const select of $('builder-fields').querySelectorAll('select'))select.disabled=!!entry.saving;
$('builder-warnings').replaceChildren(...builderWarnings(entry).map(text=>el('p',{class:'alert alert-warning',text})));
const show=!!entry.url&&!dirty;let safeUrl=null;if(show){try{const parsed=new URL(entry.url),target=state.targets.find(t=>t.id===builder.target);if(parsed.protocol==='https:'&&parsed.host===target?.domain)safeUrl=parsed.href;}catch(_){}}
$('builder-result').hidden=!safeUrl;if(safeUrl){$('builder-url').value=safeUrl;$('builder-open').href=safeUrl;$('builder-saved-at').textContent=entry.savedAt?'Выбор сохранён '+formatDate(entry.savedAt):'';}else if(show)$('builder-state').textContent='Сервер вернул недопустимую ссылку. Сформируйте её повторно.';
}
async function openBuilder(id,force=false){
builder.target=id;$('builder-target').value=id;if(!id){renderBuilder();return;}const domain=state.targets.find(t=>t.id===id)?.domain;if(builder.entries.get(id)?.ready&&builder.entries.get(id).domain===domain&&!force){renderBuilder();return;}
const request=++builder.serial;builder.entries.set(id,{ready:false,request,domain});renderBuilder();
try{const stored=await api('link-builder?target='+encodeURIComponent(id));if(!stored.selection&&!overview.entries.get(id)?.data)await(overview.pending.get(id)||refreshOverviewTarget(id));if(builder.entries.get(id)?.request!==request||!state.csrf)return;
const saved=stored.selection?{...stored.selection}:null;const draft=saved?{...saved}:{};if(!saved){const config=overview.entries.get(id)?.data?.config;for(const marker of markers){const group=groupFor(config,marker);draft[marker]=group.conflict.length?'':digitFor(marker,group.value?.default)||'';}}
builder.entries.set(id,{ready:true,request,domain,draft,saved,url:stored.url,savedAt:stored.saved_at,error:''});
}catch(error){if(builder.entries.get(id)?.request===request&&state.csrf)builder.entries.set(id,{ready:false,request,domain,error:error.message});}finally{if(builder.target===id)renderBuilder();}
}
async function saveBuilder(){
const id=builder.target,entry=builder.entries.get(id);if(!entry?.ready||!constructorValid(entry.draft)||entry.saving)return;const request=entry.request;entry.saving=true;entry.error='';updateBuilderState();
try{const result=await api('link-builder',{target:id,selection:{...entry.draft}});if(builder.entries.get(id)?.request!==request||!state.csrf)return;entry.saved={...result.selection};entry.draft={...result.selection};entry.url=result.url;entry.savedAt=result.saved_at;}catch(error){entry.error=error.message;}finally{entry.saving=false;if(builder.target===id)updateBuilderState();}
}
$('overview-refresh').addEventListener('click',()=>run(refreshOverview));
$('builder-target').addEventListener('change',event=>run(()=>openBuilder(event.target.value)));
$('builder-save').addEventListener('click',()=>run(saveBuilder));
$('builder-copy').addEventListener('click',()=>run(async()=>{const input=$('builder-url');if($('builder-result').hidden)return;try{if(!navigator.clipboard)throw new Error();await navigator.clipboard.writeText(input.value);notice('Ссылка скопирована.','success');}catch(_){input.focus();input.select();notice('Ссылка выделена. Скопируйте её через меню браузера или Ctrl+C.','info');}}));
const statuses={queued:'Ожидание',running:'Выполняется',success:'Сохранено и проверено',error:'Ошибка',conflict:'Конфликт версии — перечитайте домен',unknown:'Неизвестный результат — проверьте состояние'};
function el(tag,attrs={},...children){const n=document.createElement(tag);for(const [k,v] of Object.entries(attrs)){if(k==='class')n.className=v;else if(k==='text')n.textContent=v;else if(k.startsWith('on'))n.addEventListener(k.slice(2),v);else if(k in n && k!=='list')n[k]=v;else n.setAttribute(k,v);}for(const c of children.flat())if(c!==undefined&&c!==null)n.append(c instanceof Node?c:document.createTextNode(String(c)));return n;}
const button=(label,action,kind='default')=>{const control=el('button',{type:'button',class:'btn btn-'+kind,text:label});control.addEventListener('click',()=>run(async()=>{control.disabled=true;try{await action();}finally{control.disabled=false;for(const entry of matrix.entries.values())updateMatrixStatus(entry);renderCopy();}}));return control;};
function notice(message,type='danger'){const n=$('notice');n.className='alert alert-'+type;n.textContent=message;n.hidden=!message;}
async function api(path,data,method='POST'){
const generation=state.sessionGeneration;const options={method:data===undefined?'GET':method,credentials:'same-origin',headers:{'Accept':'application/json'}};
if(data!==undefined){options.headers['X-CSRF-Token']=state.csrf;if(data instanceof FormData)options.body=data;else{options.headers['Content-Type']='application/json';options.body=JSON.stringify(data);}}
let response;try{response=await fetch('/api/'+path,options);}catch(_){throw new Error('Связь с админкой прервана. Откройте журнал: операция могла сохраниться на сервере.');}
let body;try{body=await response.json();}catch(_){throw new Error('Сервер вернул непонятный ответ. Проверьте журнал перед повтором.');}
if(generation!==state.sessionGeneration){const error=new Error('Ответ относится к завершённому сеансу.');error.staleSession=true;throw error;}if(!response.ok||!body.ok){if(response.status===401&&generation===state.sessionGeneration)showLogin();const error=new Error(body.error||'Не удалось выполнить операцию (HTTP '+response.status+').');error.status=response.status;throw error;}return body;
}
async function run(action){try{await action();}catch(error){if(!error.staleSession)notice(error.message);}}
function showLogin(){state.sessionGeneration++;presets.serial++;presets.items=[];presets.loaded=false;presets.loading=false;presets.busy=false;presets.snapshot=null;presets.error='';$('preset-dialog').close();matrix.copyEpoch++;matrix.copyBusy=false;for(const entry of matrix.entries.values())entry.epoch++;matrix.entries.clear();matrix.structure='';matrix.copySource='';matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.clear();matrix.operationTargets.clear();utmNotes.serial++;utmNotes.entries.clear();utmNotes.loading=false;utmNotes.error='';overview.entries.clear();overview.pending.clear();builder.entries.clear();builder.target='';for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.targetsEpoch++;state.targetsBusy=false;state.targets=[];state.allTargets=[];state.source='';state.reads.clear();$('workspace').hidden=true;$('logout').hidden=true;$('login-panel').hidden=false;}
function selected(){return [...$('targets').querySelectorAll('input:checked')].map(n=>n.value);}
function targetName(id){const t=state.targets.find(t=>t.id===id);return t? t.domain+' ('+t.environment+')':id;}
function equivalent(a,b){return JSON.stringify(a)===JSON.stringify(b);}
function readCurrent(){return state.reads.get(state.source);}
function numberInput(value,min=undefined,max=undefined){const n=el('input',{class:'form-control',type:'number',value:String(value),step:'any'});if(min!==undefined)n.min=min;if(max!==undefined)n.max=max;return n;}
function labelControl(text,input){const label=el('label',{text});label.append(input);return label;}
function optionLabel(marker,value){const digit=Object.entries(enums[marker]||{}).find(([,v])=>v===value)?.[0]||value;return digit+(descriptions[marker]?.[digit]?' — '+descriptions[marker][digit]:'');}
async function fetchRead(id){const generation=state.sessionGeneration;const data=await api('config?target='+encodeURIComponent(id));if(generation===state.sessionGeneration&&state.targets.some(t=>t.id===id))state.reads.set(id,data);return data;}
async function openTarget(id){if(id&&!state.targets.some(t=>t.id===id))return;if(id!==state.source)matrix.operationTargets=new Set(id?[id]:[]);state.source=id;$('sets-body').replaceChildren();$('backups').replaceChildren();renderTargetOptions();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение конфигурации для наборов и резерва…';try{await fetchRead(id);await Promise.all([loadSets(),loadBackups()]);if(state.source===id)$('read-state').textContent='Домен прочитан.';}finally{state.loading--;}} function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:matrix.operationTargets.has(target.id)&&target.enabled,disabled:!target.enabled});const status=el('span',{class:'text-muted'});const label=el('label',{},input,targetName(target.id),status);box.append(label);input.addEventListener('change',()=>run(async()=>{if(input.checked)matrix.operationTargets.add(target.id);else matrix.operationTargets.delete(target.id);if(input.checked&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}}));}}
async function loadState(){if(state.targetsBusy)return;const epoch=state.targetsEpoch,generation=state.sessionGeneration;const data=await api('state');if(generation!==state.sessionGeneration||epoch!==state.targetsEpoch||state.targetsBusy)return;state.csrf=data.csrf;$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;acceptTargets(data.targets||[]);run(loadPresets);await loadJobs();}
function orderedResults(results){return (results||[]).filter(visibleResult).slice().sort((a,b)=>{const id=r=>typeof r.target==='object'?r.target.id:r.target;return state.targets.findIndex(t=>t.id===id(a))-state.targets.findIndex(t=>t.id===id(b));});}
function visibleResult(result){const id=typeof result.target==='object'?result.target.id:result.target;return state.targets.some(t=>t.id===id);}
function acceptTargets(targets,refresh=true){
state.allTargets=targets;state.targets=targets.filter(t=>!t.hidden);const ids=new Set(state.targets.map(t=>t.id));
for(const id of state.reads.keys())if(!ids.has(id))state.reads.delete(id);
if(state.source&&!ids.has(state.source)){state.source='';$('sets-body').replaceChildren();$('backups').replaceChildren();$('read-state').textContent='Выберите домен.';if($('indpic-result-modal').open)$('indpic-result-modal').close();}
const form=$('connection-form');if(form.elements.id.readOnly&&!ids.has(form.elements.id.value)){$('connection-new').click();}
const source=$('source-target');source.replaceChildren(el('option',{value:'',text:'Выберите домен'}),...state.targets.map(t=>el('option',{value:t.id,text:targetName(t.id)})));source.value=state.source;
renderTargetOptions();renderConnections();syncOverview(refresh);renderJobs();renderHidden();
}
async function mutateTargets(path,data,method='POST'){
if(state.targetsBusy)return;const generation=state.sessionGeneration;state.targetsBusy=true;state.targetsEpoch++;renderTargetControls();
try{const result=await api(path,data,method);if(generation!==state.sessionGeneration||!state.csrf)return;if(result.targets)acceptTargets(result.targets,path!=='targets/order');else{const snapshot=await api('state');if(generation===state.sessionGeneration&&state.csrf)acceptTargets(snapshot.targets||[]);}if(generation===state.sessionGeneration&&state.csrf){const message=path==='targets/order'?'Порядок доменов сохранён.':path==='targets/visibility'?(data.hidden?'Домен скрыт.':'Домен возвращён.'):method==='DELETE'?'Подключение удалено.':'';if(message)notice(message,'success');}}
finally{if(generation===state.sessionGeneration){state.targetsBusy=false;renderTargetControls();}}
}
function renderTargetControls(){document.querySelectorAll('.domain-hide,.domain-grip,#hidden-domains button').forEach(n=>n.disabled=state.targetsBusy);$('connection-form').querySelector('button[type=submit],button:not([type])')?.toggleAttribute('disabled',state.targetsBusy);}
function hideTarget(id){const entry=matrix.entries.get(id);if(entry?.dirty||entry?.saving||matrix.copyBusy||state.loading){notice('Сначала сохраните или отмените изменения домена и дождитесь завершения текущей операции.');return;}return mutateTargets('targets/visibility',{id,hidden:true});}
function renderHidden(){const hidden=state.allTargets.filter(t=>t.hidden);$('hidden-toggle').textContent='Скрытые домены ('+hidden.length+')';const box=$('hidden-list');box.replaceChildren();if(!hidden.length)box.append(el('p',{class:'text-muted',text:'Скрытых доменов пока нет.'}));for(const target of hidden)box.append(el('div',{class:'hidden-domain-row','data-hidden-target':target.id},el('span',{class:'grow break'},el('strong',{text:target.domain}),el('span',{class:'text-muted',text:' · '+target.environment})),Object.assign(button('Вернуть',()=>mutateTargets('targets/visibility',{id:target.id,hidden:false})),{ariaLabel:'Вернуть домен '+target.domain}),Object.assign(button('Удалить',()=>{if(confirm('Удалить подключение '+target.domain+' из админки? Сайт, файлы, DNS и регистрация домена останутся без изменений.'))return mutateTargets('targets',{id:target.id},'DELETE');},'danger'),{ariaLabel:'Удалить домен '+target.domain})));renderTargetControls();}
function reorderTarget(id,before){if(state.targetsBusy)return;const ids=state.targets.map(t=>t.id),next=ids.filter(v=>v!==id),index=before?next.indexOf(before):next.length;next.splice(index<0?next.length:index,0,id);if(next.join('|')===ids.join('|'))return;return mutateTargets('targets/order',{ids:next});}
function domainHeading(target){
const grip=el('button',{type:'button',class:'domain-grip',text:'⠿',title:'Перетащить домен; стрелки влево/вправо меняют порядок','aria-label':'Переместить домен '+target.domain});
grip.addEventListener('keydown',event=>{if(!['ArrowLeft','ArrowRight'].includes(event.key))return;event.preventDefault();const ids=state.targets.map(t=>t.id),i=ids.indexOf(target.id),next=i+(event.key==='ArrowRight'?1:-1);if(next<0||next>=ids.length)return;run(()=>reorderTarget(target.id,event.key==='ArrowRight'?ids[next+1]:ids[next]));});
grip.addEventListener('pointerdown',event=>startDomainDrag(event,target.id));
const hide=el('button',{type:'button',class:'domain-hide',title:'Скрыть домен','aria-label':'Скрыть домен '+target.domain,onclick:()=>run(()=>hideTarget(target.id))});
hide.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="m3 3 18 18M10.6 10.6a2 2 0 0 0 2.8 2.8M9.5 5.3A11 11 0 0 1 12 5c6 0 10 7 10 7a17 17 0 0 1-3.4 4.1M6.2 6.2C3.6 8 2 12 2 12s4 7 10 7a11 11 0 0 0 5.1-1.4"/></svg>';
const save=el('button',{type:'button',class:'domain-preset',title:'Сохранить UTM-настройки','aria-label':'Сохранить UTM-набор '+target.domain,onclick:()=>openPreset(target.id)});
save.innerHTML='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="M5 3h12l4 4v14H3V3h2Zm2 0v6h10V3M7 21v-8h10v8"/></svg>';
return el('div',{class:'domain-heading'},grip,el('span',{class:'overview-domain',text:target.domain}),save,hide);
}
function startDomainDrag(event,id){
if(event.button!==0||state.targetsBusy)return;event.preventDefault();const grip=event.currentTarget,wrapper=$('overview-table').closest('.overview-wrapper'),header=grip.closest('th'),preview=header.cloneNode(true),marker=el('div',{class:'domain-insertion'});preview.className='domain-drag-preview';preview.style.width=header.offsetWidth+'px';document.body.append(preview,marker);let before=id,x=event.clientX,y=event.clientY,frame;
const move=()=>{preview.style.left=x+12+'px';preview.style.top=y+12+'px';const rect=wrapper.getBoundingClientRect();if(x<rect.left+45)wrapper.scrollLeft-=15;else if(x>rect.right-45)wrapper.scrollLeft+=15;const heads=[...$('overview-table').querySelectorAll('[data-overview-target]')].filter(n=>n.dataset.overviewTarget!==id);const next=heads.find(n=>x<n.getBoundingClientRect().left+n.offsetWidth/2);before=next?.dataset.overviewTarget||null;const edge=next?.getBoundingClientRect().left||heads.at(-1)?.getBoundingClientRect().right||rect.left;marker.style.cssText='left:'+Math.max(rect.left,Math.min(rect.right,edge))+'px;top:'+rect.top+'px;height:'+Math.min(rect.height,innerHeight-rect.top)+'px';frame=requestAnimationFrame(move);};
const pointerMove=e=>{x=e.clientX;y=e.clientY;};const finish=e=>{cancelAnimationFrame(frame);document.removeEventListener('pointermove',pointerMove);document.removeEventListener('pointerup',finish);document.removeEventListener('pointercancel',cancel);preview.remove();marker.remove();document.body.classList.remove('domain-dragging');if(e.type==='pointerup')run(()=>reorderTarget(id,before));};const cancel=e=>finish(e);document.body.classList.add('domain-dragging');document.addEventListener('pointermove',pointerMove);document.addEventListener('pointerup',finish);document.addEventListener('pointercancel',cancel);move();
}
function safeTargets(){const ids=selected();if(!ids.length)throw new Error('Выберите хотя бы один домен.');if(state.loading||ids.some(id=>!state.reads.has(id)))throw new Error('Сначала перечитайте выбранные домены и дождитесь загрузки.');return ids;}
async function applyChanges(changes,ids=safeTargets()){
if(!changes.length)throw new Error('Выберите поля для применения.');
const targets=ids.map(id=>{const read=state.reads.get(id);if(!read)throw new Error('Сначала прочитайте конфигурацию '+targetName(id));return {id,version:read.version};});
try{const result=await api('apply',{targets,changes});notice('Задание принято. Проверяйте результат каждой цели в журнале.','info');await showJob(result.job);}finally{}
}
async function loadSets(){if(!state.source)return;const targetId=state.source;const data=await api('sets?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;const current=readCurrent();if(current)current.sets=data.sets;const body=$('sets-body');body.replaceChildren();for(const set of (data.sets||[]).filter(s=>s.exists||s.registered||s.in_use)){const tr=el('tr',{class:'indpic-set-row'});const name=el('input',{class:'form-control utm-group-name',value:set.name||'',maxLength:255,'aria-label':'Название Set '+set.number});const namecell=el('td',{},el('span',{class:'utm-group-number',text:String(set.number)}),name,el('button',{type:'button',class:'utm-group-save',text:set.registered?'Сохранить':'Проверить и добавить',onclick:()=>run(async()=>{if(set.registered){await api('sets/name',{target:state.source,number:set.number,name:name.value});notice('Название сохранено.','success');}else{const result=await api('sets/register',{targets:[state.source],number:set.number,name:name.value});await showJob(result.job,true);}})}));const def=el('td');if(set.default)def.append(el('span',{class:'utm-default-value',text:'default'}));else def.append(el('button',{type:'button',class:'utm-default-action',text:'назначить',onclick:()=>run(()=>applyChanges([{path:['utm','indpic','default'],value:String(set.number)}],[state.source]))}));
const file=el('input',{type:'file',accept:'.zip,application/zip','aria-label':'ZIP Set '+set.number});const zip=el('td',{},el('div',{class:'indpic-file-control'},file,button('Загрузить ZIP',()=>uploadSet(file.files[0],set.number,name.value,set.in_use))));
const actions=el('td');if(!set.default&&!set.in_use&&set.registered)actions.append(button('Удалить',async()=>{if(confirm('Удалить Set '+set.number+' из реестра? Файлы на хосте останутся.')){await api('sets/delete',{target:state.source,number:set.number});await loadSets();}},'danger'));else actions.append(el('span',{class:'text-muted',text:set.in_use?'Используется':'Не в реестре'}));
tr.append(namecell,def,el('td',{},button('Проверить',async()=>{const r=await api('sets/check',{targets:safeTargets(),number:set.number});await showJob(r.job,true);})),zip,actions);body.append(tr);
}if(!(data.sets||[]).some(s=>s.exists||s.registered||s.in_use))body.append(el('tr',{},el('td',{colSpan:5,text:'Наборы ещё не зарегистрированы.'})));
}
async function uploadSet(file,number,name,inUse=false){if(!file)throw new Error('Выберите ZIP-файл.');const ids=safeTargets();const existing=ids.filter(id=>(state.reads.get(id)?.sets||[]).some(s=>String(s.number)===String(number)&&(s.exists||s.in_use)));if((inUse||existing.length)&&!confirm('Заменить существующий набор '+number+' на выбранных доменах? Он может использоваться как default или с ненулевым весом. Default и веса не изменятся.'))return;const form=new FormData();form.set('file',file);form.set('number',number);form.set('name',name);form.set('targets',JSON.stringify(safeTargets()));const r=await api('sets/upload',form);await showJob(r.job,true);}
function modal(title,text){$('result-title').textContent=title;$('result-body').textContent=text;if(!$('indpic-result-modal').open)$('indpic-result-modal').showModal();}
function renderJobs(){const box=$('jobs');box.replaceChildren();const visibleJobs=state.jobs.filter(job=>orderedResults(job.results).length);if(!visibleJobs.length)box.append(el('p',{class:'text-muted',text:'Заданий для видимых доменов пока нет.'}));for(const job of visibleJobs){const block=el('div',{class:'job'},el('strong',{class:'break',text:'Задание '+job.id+' · '+(job.status==='completed'?'Завершено':statuses[job.status]||job.status)}));if(job.created_at)block.append(el('p',{class:'text-muted',text:formatDate(job.created_at)}));if(job.changes)block.append(el('p',{class:'text-muted break',text:'Поля: '+job.changes.map(c=>Array.isArray(c.path)?c.path.join('.'):String(c.path)).join(', ')}));const table=el('table',{class:'table table-bordered'},el('thead',{},el('tr',{},el('th',{text:'Домен'}),el('th',{text:'Результат'}),el('th',{text:'Повтор'}))));const body=el('tbody');const retry=[];for(const result of orderedResults(job.results)){const id=typeof result.target==='object'?result.target.id:result.target;const tr=el('tr');const outcome=el('td',{class:'status-'+result.status},el('strong',{text:statuses[result.status]||result.status}),el('div',{class:'break',text:[result.message,...(result.warnings||[])].filter(Boolean).join(' · ')}));const action=el('td');if(['error','conflict','unknown'].includes(result.status)){const check=el('input',{type:'checkbox',value:id,'aria-label':'Повтор '+targetName(id),checked:false});retry.push(check);action.append(check);}tr.append(el('td',{text:targetName(id)}),outcome,action);body.append(tr);}table.append(body);block.append(el('div',{class:'table-responsive'},table));if(retry.length)block.append(button('Повторить выбранные неуспешные цели',async()=>{const ids=retry.filter(c=>c.checked).map(c=>c.value);if(!ids.length)throw new Error('Отметьте неуспешные цели для повтора.');if((job.results||[]).some(r=>ids.includes(r.target)&&r.status==='conflict')){throw new Error('Конфликт нельзя повторить со старой версией. Перечитайте домен, проверьте значения и примените поля заново.');}const r=await api('jobs/retry',{id:job.id,targets:ids});await showJob(r.job);}));box.append(block);}}
async function loadJobs(){const r=await api('jobs');state.jobs=(r.jobs||[]).slice().sort((a,b)=>String(b.created_at||'').localeCompare(String(a.created_at||'')));renderJobs();for(const job of state.jobs)if(job.status!=='completed'&&!pollTimers.has(job.id))pollTimers.set(job.id,setTimeout(()=>run(()=>pollJob(job.id,false)),1400));}
const pollTimers=new Map();
async function showJob(job,dialog=false){const id=typeof job==='string'?job:job.id;if(typeof job==='object'){state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();}if(dialog)modal('Проверка / публикация indpic','Задание '+id+' принято. Ожидание проверки…');await pollJob(id,dialog);}
async function pollJob(id,dialog){pollTimers.delete(id);const r=await api('jobs?id='+encodeURIComponent(id));const job=r.job;state.jobs=state.jobs.filter(j=>j.id!==id);state.jobs.unshift(job);renderJobs();if(dialog&&$('indpic-result-modal').open){modal('Результат проверки indpic',orderedResults(job.results).map(v=>targetName(typeof v.target==='object'?v.target.id:v.target)+'\n'+(statuses[v.status]||v.status)+'\n'+(v.message||'')).join('\n\n')||'Ожидание…');}if(job.status!=='completed'){clearTimeout(pollTimers.get(id));pollTimers.set(id,setTimeout(()=>run(()=>pollJob(id,dialog)),1400));}else{clearTimeout(pollTimers.get(id));pollTimers.delete(id);const configChanged=['apply','restore','copy'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success'&&visibleResult(result)){state.reads.delete(result.target);run(()=>refreshOverviewTarget(result.target));}if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}}}
async function loadBackups(){const box=$('backups');box.replaceChildren();if(!state.source)return;const targetId=state.source;const r=await api('backups?target='+encodeURIComponent(targetId));if(targetId!==state.source)return;for(const backup of r.backups||[]){box.append(el('div',{class:'toolbar'},el('span',{class:'break',text:(backup.created_at?formatDate(backup.created_at):'')+' · '+backup.id}),button('Восстановить',async()=>{if(!confirm('Восстановить резерв '+backup.id+' на '+targetName(state.source)+'?'))return;const read=readCurrent();if(!read)throw new Error('Перед восстановлением перечитайте текущий домен.');const result=await api('restore',{target:state.source,id:backup.id,version:read.version});await showJob(result.job);})));}if(!(r.backups||[]).length)box.append(el('p',{class:'text-muted',text:'Резервных копий пока нет.'}));}
function renderConnections(){const list=$('connection-list');list.replaceChildren();for(const t of state.targets){list.append(el('div',{class:'connection'},el('span',{class:'break',text:t.name+' · '+targetName(t.id)+' · '+t.type+' · '+(t.enabled?'включено':'отключено')+(t.verified?' · соединение проверено':' · требуется проверка')+(t.has_secret?' · секрет сохранён':'')}),button('Изменить',()=>{const form=$('connection-form');form.reset();for(const key of ['id','name','domain','environment','type','host','port','user','root','host_key'])form.elements[key].value=t[key]??'';form.elements.enabled.checked=!!t.enabled;form.elements.id.readOnly=true;$('connection-title').textContent='Изменить подключение';form.elements.name.focus();}),button('Проверить соединение',async()=>{const result=await api('targets/test',{id:t.id});if(state.targets.includes(t)){t.verified=true;renderConnections();renderOverview();}modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');})));}}
$('hidden-toggle').addEventListener('click',()=>{$('hidden-domains').hidden=false;$('hidden-domains').scrollIntoView({block:'start'});});$('hidden-close').addEventListener('click',()=>{$('hidden-domains').hidden=true;$('overview').scrollIntoView({block:'start'});});
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;state.sessionGeneration++;state.targetsEpoch++;try{const data=await api('login',{username:form.username.value,password:form.password.value,remember:form.remember.checked});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
$('logout').addEventListener('click',()=>run(async()=>{if(matrixDirty()&&!confirm('Есть несохранённые изменения настроек или заметок. Выйти?'))return;await api('logout',{});for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();showLogin();notice('Сеанс завершён.','info');}));
$('source-target').addEventListener('change',event=>run(()=>openTarget(event.target.value)));
$('reload').addEventListener('click',()=>run(()=>openTarget(state.source)));
$('copy-source').addEventListener('change',e=>{matrix.copySource=e.target.value;matrix.copyError='';matrix.copyResults=[];renderCopy();});
$('copy-all').addEventListener('click',()=>{matrix.copyTargets=new Set(state.targets.filter(t=>t.enabled&&t.verified).map(t=>t.id));renderCopy();});
$('copy-none').addEventListener('click',()=>{matrix.copyTargets.clear();renderCopy();});
$('preset-form').addEventListener('submit',event=>run(()=>savePreset(event)));
$('preset-cancel').addEventListener('click',()=>{$('preset-dialog').close();presets.snapshot=null;});
$('preset-dialog').addEventListener('cancel',event=>{if(presets.busy)event.preventDefault();else presets.snapshot=null;});
$('preset-delete').addEventListener('click',()=>run(deletePreset));
$('preset-reload').addEventListener('click',()=>run(loadPresets));
$('copy-submit').addEventListener('click',()=>run(copySettings)); $('set-add').elements.sftp.addEventListener('change',event=>{const form=$('set-add');form.querySelector('.zip-field').hidden=event.target.checked;form.elements.file.required=!event.target.checked;form.querySelector('button').textContent=event.target.checked?'Проверить и добавить':'Загрузить сет';});
$('set-add').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const number=Number(form.elements.number.value);if(form.elements.sftp.checked){const r=await api('sets/register',{targets:safeTargets(),number,name:form.elements.name.value});await showJob(r.job,true);}else await uploadSet(form.elements.file.files[0],number,form.elements.name.value,false);});});
$('connection-new').addEventListener('click',()=>{$('connection-form').reset();$('connection-form').elements.id.readOnly=false;$('connection-title').textContent='Добавить подключение';});
$('connection-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const target={};for(const key of ['id','name','domain','environment','type','host','user','root','host_key'])target[key]=form.elements[key].value;target.port=Number(form.elements.port.value);target.enabled=form.elements.enabled.checked;const data={target};if(form.elements.secret.value)data.secret=form.elements.secret.value;await mutateTargets('targets',data);form.elements.secret.value='';notice('Подключение сохранено. Проверьте соединение перед записью.','success');});});
$('refresh-history').addEventListener('click',()=>run(async()=>{await Promise.all([loadJobs(),loadBackups()]);notice('Журнал обновлён.','info');}));
for(const id of ['result-close','result-done'])$(id).addEventListener('click',()=>$('indpic-result-modal').close());
window.addEventListener('beforeunload',event=>{if(matrixDirty()){event.preventDefault();event.returnValue='';}});
run(async()=>{try{await loadState();}catch(error){if(error.status===401)showLogin();else throw error;}});
})();
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,LinkBuilder,Notes,Copy,UtmPresets};
header('X-Content-Type-Options: nosniff');header('X-Frame-Options: DENY');header('Referrer-Policy: same-origin');header('Cache-Control: no-store');
$path=parse_url($_SERVER['REQUEST_URI']??'/',PHP_URL_PATH);
if(!str_starts_with((string)$path,'/api/')) {require dirname(__DIR__).'/views/dashboard.php';exit;}
header('Content-Type: application/json; charset=utf-8');
function reply(array $payload,int $status=200): never {http_response_code($status);echo json_encode(['ok'=>true]+$payload,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR);exit;}
try {
Auth::session();$store=new Store();$auth=new Auth($store);$jobs=new Jobs($store);$sets=new Sets($store);$method=$_SERVER['REQUEST_METHOD']??'GET';$data=[];
if(!in_array($method,['GET','POST','DELETE','PUT'],true))throw new Problem('Метод не поддерживается.',405);
if($method!=='GET'){
$origin=$_SERVER['HTTP_ORIGIN']??'';
if($origin!=='' && strcasecmp((string)parse_url($origin,PHP_URL_HOST),(string)preg_replace('/:\d+$/','',$_SERVER['HTTP_HOST']??''))!==0)throw new Problem('Недопустимый источник запроса.',403);
if(!str_starts_with($_SERVER['CONTENT_TYPE']??'','multipart/form-data')){
if((int)($_SERVER['CONTENT_LENGTH']??0)>2097152)throw new Problem('Запрос слишком большой.',413);
$raw=file_get_contents('php://input',false,null,0,2097153);if(strlen($raw)>2097152)throw new Problem('Запрос слишком большой.',413);
if($raw!==''){$object=json_decode($raw,false,32,JSON_THROW_ON_ERROR);if(!$object instanceof stdClass)throw new Problem('Ожидался объект запроса.');$data=(array)$object;}
}else $data=$_POST;
}
if($path==='/api/login'&&$method==='POST'){
if(array_key_exists('remember',$data)&&!is_bool($data['remember']))throw new Problem('Поле «Запомнить меня» должно быть логическим значением.');
reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown',$data['remember']??false));
}
$user=$auth->requireUser();
if($method!=='GET')$auth->csrf($_SERVER['HTTP_X_CSRF_TOKEN']??'');
if($path==='/api/logout'&&$method==='POST'){$auth->logout();reply([]);}
// Авторизация, idle timestamp и CSRF уже проверены; SSH/файловые операции
// не должны удерживать PHP session lock и блокировать соседние запросы.
session_write_close();
// Удаление и постановка заданий используют одну блокировку: транспорт нельзя
// удалить между проверкой существования подключения и записью задания.
$enqueue=static function(callable $create,array $selections)use($store){return $store->locked('targets',function()use($create,$selections,$store){foreach($selections as $selection){$id=is_array($selection)?($selection['id']??null):$selection;if(!is_string($id))throw new Problem('Некорректная цель задания.');$store->target($id);}return $create();});};
if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]);
if($path==='/api/utm-notes'){
$notes=new Notes($store);
if($method==='GET'){
if($_GET)throw new Problem('Заметки UTM общие для всех доменов и не принимают параметры.');
reply(['notes'=>$notes->all()]);
}
if($method==='POST')reply(['note'=>$notes->save($data)]);
throw new Problem('Метод заметок UTM не поддерживается.',405);
}
if($path==='/api/link-builder'){
$builder=new LinkBuilder($store);
if($method==='GET'){
if(array_diff(array_keys($_GET),['target'])||!is_string($_GET['target']??null))throw new Problem('Укажите ID подключения конструктора ссылки.');
reply($builder->get($_GET['target']));
}
if($method==='POST'){
if(count($data)!==2||array_diff(array_keys($data),['target','selection'])||!is_string($data['target']??null)||!(($data['selection']??null) instanceof stdClass))throw new Problem('Передайте ID подключения и объект выбора конструктора ссылки.');
reply($builder->save($data['target'],get_object_vars($data['selection'])));
}
throw new Problem('Метод конструктора ссылки не поддерживается.',405);
}
if($path==='/api/targets'&&$method==='POST')reply(['target'=>$store->saveTarget((array)($data['target']??[]),isset($data['secret'])?(string)$data['secret']:null)]);
if($path==='/api/targets/visibility'&&$method==='POST'){
if(count($data)!==2||array_diff(array_keys($data),['id','hidden'])||!is_string($data['id']??null)||!is_bool($data['hidden']??null))throw new Problem('Передайте строковый ID и логическое значение hidden.');
reply(['targets'=>$store->setTargetVisibility($data['id'],$data['hidden'])]);
}
if($path==='/api/targets/order'&&$method==='POST'){
if(count($data)!==1||!is_array($data['ids']??null)||!array_is_list($data['ids']))throw new Problem('Передайте список строковых ID в поле ids.');
reply(['targets'=>$store->orderTargets($data['ids'])]);
}
if($path==='/api/targets'&&$method==='DELETE'){$store->deleteTarget((string)($data['id']??''));reply([]);}
if($path==='/api/targets/test'&&$method==='POST'){$id=(string)($data['id']??'');$t=$store->target($id);(new Transport($store,$t))->test();$store->verifyTarget($id,$t);reply(['message'=>'Пути, блокировка, чтение и права записи проверены.']);}
if($path==='/api/config'&&$method==='GET'){
$target=$store->target((string)($_GET['target']??''));$read=(new Transport($store,$target))->readConfig();$readAt=gmdate(DATE_ATOM);
reply(['config'=>$read['config'],'version'=>$read['version'],'read_at'=>$readAt,'warnings'=>[],'sets'=>$sets->list($target,$read['config'])]);
}
if($path==='/api/utm-presets'){
$presets=new UtmPresets($store);
if($method==='GET'){if($_GET)throw new Problem('Список наборов не привязан к доменам.');reply(['presets'=>$presets->all()]);}
if($method==='POST')reply(['preset'=>$presets->create($data)],201);
if($method==='DELETE'){if(count($data)!==1||!is_string($data['id']??null))throw new Problem('Передайте только ID набора.');$presets->delete($data['id']);reply([]);}
throw new Problem('Метод наборов не поддерживается.',405);
}
if($path==='/api/copy'){
if($method!=='POST')throw new Problem('Метод копирования не поддерживается.',405);
reply(['job'=>$enqueue(fn()=>(new Copy($store))->create($data),[])],202);
}
if($path==='/api/apply'&&$method==='POST'){$data['targets']=array_map(static fn($t)=>(array)$t,(array)($data['targets']??[]));$data['changes']=array_map(static fn($c)=>(array)$c,(array)($data['changes']??[]));reply(['job'=>$enqueue(fn()=>$jobs->create('apply',$data),$data['targets'])],202);}
if($path==='/api/jobs'&&$method==='GET')reply(isset($_GET['id'])?['job'=>$jobs->get((string)$_GET['id'])]:['jobs'=>$jobs->all()]);
if($path==='/api/jobs/retry'&&$method==='POST')reply(['job'=>$enqueue(fn()=>$jobs->retry((string)($data['id']??''),(array)($data['targets']??[])),(array)($data['targets']??[]))],202);
if($path==='/api/backups'&&$method==='GET'){$id=(string)($_GET['target']??'');$store->target($id);reply(['backups'=>$store->backups($id)]);}
if($path==='/api/restore'&&$method==='POST')reply(['job'=>$enqueue(fn()=>$jobs->create('restore',$data),[$data['target']??null])],202);
if($path==='/api/sets'&&$method==='GET'){$target=$store->target((string)($_GET['target']??''));$read=(new Transport($store,$target))->readConfig();reply(['sets'=>$sets->list($target,$read['config']),'version'=>$read['version']]);}
if($path==='/api/sets/upload'&&$method==='POST'){
if(($_FILES['file']['error']??UPLOAD_ERR_NO_FILE)!==UPLOAD_ERR_OK||!is_uploaded_file($_FILES['file']['tmp_name']))throw new Problem('ZIP не получен либо превышает лимит 64 МиБ.');
$number=Sets::number($data['number']??0);$prepared=$sets->prepareZip($_FILES['file']['tmp_name'],$number);
$targets=json_decode((string)($data['targets']??'[]'),true,16,JSON_THROW_ON_ERROR);
reply(['job'=>$enqueue(fn()=>$jobs->create('upload',['targets'=>$targets,'number'=>$number,'name'=>(string)($data['name']??''),'prepared'=>$prepared]),$targets)],202);
}
if(in_array($path,['/api/sets/check','/api/sets/register'],true)&&$method==='POST'){$data['number']=Sets::number($data['number']??0);reply(['job'=>$enqueue(fn()=>$jobs->create($path==='/api/sets/check'?'check':'register',$data),(array)($data['targets']??(isset($data['target'])?[$data['target']]:[])))],202);}
if(in_array($path,['/api/sets/name','/api/sets/delete'],true)&&$method==='POST'){$target=$store->target((string)($data['target']??''));$number=Sets::number($data['number']??0);if($path==='/api/sets/name')$sets->name($target,$number,(string)($data['name']??''));else $sets->delete($target,$number);reply([]);}
throw new Problem('Маршрут не найден.',404);
}catch(Problem $e){
// Запоздалый 401 (анонимный запрос, истечение сессии, ошибка входа) не должен
// подменять или удалять cookie более нового успешного входа. Истёкший backing
// Auth уже отзывает на сервере; явный logout возвращает 200 и очищает cookie.
if($e->status===401)header_remove('Set-Cookie');
http_response_code($e->status);echo json_encode(['ok'=>false,'error'=>$e->getMessage()],JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);
}
catch(JsonException){http_response_code(400);echo json_encode(['ok'=>false,'error'=>'Некорректный JSON запроса.'],JSON_UNESCAPED_UNICODE);}
catch(Throwable){http_response_code(500);echo json_encode(['ok'=>false,'error'=>'Операция не выполнена. Проверьте доступность и повторите.'],JSON_UNESCAPED_UNICODE);}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Auth
{
private const REMEMBER_SECONDS=90*86400;
private static bool $rememberSession=false;
public function __construct(private Store $store) {}
public static function session(): void {
if(session_status()===PHP_SESSION_ACTIVE)return;
$directory=self::sessionDirectory();
$rememberDirectory=self::sessionDirectory('remember-sessions',false);
$incoming=$_COOKIE['preland_admin']??null;
$validId=is_string($incoming)&&preg_match('/^[a-zA-Z0-9,-]{22,256}$/D',$incoming)===1;
if(!$validId)unset($_COOKIE['preland_admin']);
$remember=false;
if($validId&&$rememberDirectory!==null){
$candidate=$rememberDirectory.'/sess_'.$incoming;clearstatcache(true,$candidate);
if(is_link($candidate))throw new Problem('Символические ссылки файлов сессий запрещены.',503);
$stat=@lstat($candidate);$remember=$stat!==false&&($stat['mode']&0170000)===0100000;
}
self::configure($remember?$rememberDirectory:$directory,$remember);
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
// Между проверкой SID и session_start файл мог быть отозван logout другого запроса.
// Новый анонимный SID не должен оставаться в долгоживущем каталоге.
if($remember&&(session_id()!==$incoming||empty($_SESSION['user'])||!is_int($_SESSION['remember_until']??null))){
self::destroy(false);self::configure($directory,false);
if(!@session_start())throw new Problem('Не удалось открыть приватное хранилище сессий.',503);
}
}
private static function configure(string $directory,bool $remember): void {
if(ini_get('session.save_handler')!=='files'||session_save_path($directory)===false)throw new Problem('Файловое хранилище сессий недоступно.',503);
ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1');
ini_set('session.gc_maxlifetime',(string)($remember?self::REMEMBER_SECONDS:3600));ini_set('session.gc_probability','1');ini_set('session.gc_divisor','100');
session_name('preland_admin');
session_set_cookie_params(['lifetime'=>$remember?self::REMEMBER_SECONDS:0]+self::cookieOptions());
self::$rememberSession=$remember;
}
private static function cookieOptions(): array {return ['path'=>'/','domain'=>(string)ini_get('session.cookie_domain'),'secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict'];}
private static function destroy(bool $clearCookie): void {
$_SESSION=[];
if(session_status()===PHP_SESSION_ACTIVE&&!@session_destroy())throw new Problem('Не удалось отозвать сессию.',503);
unset($_COOKIE['preland_admin']);session_id('');
if($clearCookie)setcookie('preland_admin','',['expires'=>time()-3600]+self::cookieOptions());
self::$rememberSession=false;
}
/** Готовит приватный backing до Store: не создаёт данные в небезопасном state. */
private static function sessionDirectory(string $bucket='sessions',bool $create=true): ?string {
$state=rtrim(getenv('ADMIN_STATE_DIR')?:'/var/lib/preland-admin','/');
if($state===''||!str_starts_with($state,'/')||str_contains($state,'\\')||preg_match('/[\x00-\x1f]/',$state))throw new Problem('Для сессий требуется абсолютный приватный путь состояния.',503);
$parts=explode('/',substr($state,1));
foreach($parts as $part)if($part===''||$part==='.'||$part==='..')throw new Problem('Некорректный путь приватного состояния сессий.',503);
$publicRoots=[dirname(__DIR__).'/public',$_SERVER['DOCUMENT_ROOT']??''];
foreach(explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'') as $root)$publicRoots[]=trim($root);
foreach($publicRoots as $root){$root=rtrim($root,'/');if($root!==''&&($state===$root||str_starts_with($state,$root.'/')))throw new Problem('Сессии нельзя хранить в публичном каталоге.',503);}
$current='';
foreach([...$parts,$bucket] as $part){
$current.='/'.$part;clearstatcache(true,$current);
if(is_link($current))throw new Problem('Символические ссылки в пути сессий запрещены.',503);
if(!is_dir($current)){
if(file_exists($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503);
if(!$create)return null;
if(!@mkdir($current,0700)&&!is_dir($current))throw new Problem('Не удалось подготовить приватное хранилище сессий.',503);
}
clearstatcache(true,$current);
if(is_link($current)||realpath($current)!==$current)throw new Problem('Небезопасный путь хранилища сессий.',503);
}
if(!@chmod($current,0700)||!is_readable($current)||!is_writable($current))throw new Problem('Приватное хранилище сессий недоступно.',503);
return $current;
}
public function login(string $username,string $password,string $ip,bool $remember=false): array {
$key=hash('sha256',$ip);$this->store->locked('login',function() use($username,$password,$key){
$attempts=$this->store->read('login-attempts.json');$now=time();$entry=$attempts[$key]??['count'=>0,'since'=>$now];if($entry['since']<$now-900)$entry=['count'=>0,'since'=>$now];
if($entry['count']>=10)throw new Problem('Слишком много попыток. Подождите 15 минут.',429);
$expected=getenv('ADMIN_USERNAME')?:'';$hash=getenv('ADMIN_PASSWORD_HASH')?:'';
if($expected===''||$hash==='')throw new Problem('Администратор ещё не настроен.',503);
$valid=password_verify($password,$hash)&&hash_equals($expected,$username);
if(!$valid){$entry['count']++;$attempts[$key]=$entry;$attempts=array_filter($attempts,static fn($e)=>$e['since']>$now-900);$this->store->write('login-attempts.json',$attempts);throw new Problem('Неверный логин или пароль.',401);}
unset($attempts[$key]);$this->store->write('login-attempts.json',$attempts);
});
// Каталог проверяется после пароля, но до уничтожения действующего входа.
$directory=self::sessionDirectory($remember?'remember-sessions':'sessions');
self::destroy(false);self::configure($directory,$remember);
if(!@session_start())throw new Problem('Не удалось создать новую сессию.',503);
$now=time();$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>$now];
if($remember)$_SESSION['remember_until']=$now+self::REMEMBER_SECONDS;
return ['user'=>$username,'csrf'=>$_SESSION['csrf']];
}
public function requireUser(): string {
$now=time();$expired=self::$rememberSession?(!is_int($_SESSION['remember_until']??null)||$_SESSION['remember_until']<=$now):(($_SESSION['last']??0)<$now-3600);
if(empty($_SESSION['user'])||$expired){if(self::$rememberSession||!empty($_SESSION['user']))self::destroy(true);else $_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=$now;return $_SESSION['user'];
}
public function csrf(string $token): void {if(!$token||!hash_equals($_SESSION['csrf']??'', $token))throw new Problem('Сессия формы истекла. Обновите страницу.',403);}
public function logout(): void {self::destroy(true);}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Config
{
public static function decode(string $json): \stdClass {
try { $value = json_decode($json, false, 64, JSON_THROW_ON_ERROR); } catch (\Throwable) { throw new Problem('Настройки содержат некорректный JSON.'); }
if (!$value instanceof \stdClass) throw new Problem('Настройки должны быть объектом.');
return $value;
}
public static function encode(\stdClass $value): string { return json_encode($value, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR) . "\n"; }
/** Старый raw читается без миграции, но повторная публикация chb запрещена. */
public static function assertWritable(\stdClass $config): void {
if(($config->utm??null) instanceof \stdClass&&property_exists($config->utm,'chb'))throw new Problem('Устаревший ключ utm.chb нельзя записывать. Явно удалите его перед сохранением; восстановление такого архива запрещено.');
}
public static function patch(\stdClass $config, array $changes): \stdClass {
if (!$changes || count($changes)>500) throw new Problem('Выберите изменённые поля (не более 500).');
$copy = self::decode(self::encode($config));
foreach ($changes as $change) {
$change=self::adaptChange($copy,$change);
$path = $change['path'] ?? [];
if (!is_array($path) || !$path || count($path)>16) throw new Problem('Некорректный путь поля.');
$ref = &$copy;
foreach ($path as $i=>$part) {
if (!is_string($part) || $part==='' || strlen($part)>100 || in_array($part,['__proto__','constructor','prototype'],true) || preg_match('/[\x00-\x1f]/',$part)) throw new Problem('Недопустимый ключ поля.');
$last = $i===count($path)-1;
if ($ref instanceof \stdClass) {
if ($last) { if (!empty($change['delete'])) unset($ref->$part); else $ref->$part=$change['value'] ?? null; }
else { if (!property_exists($ref,$part)) $ref->$part=new \stdClass(); $ref=&$ref->$part; }
} elseif (is_array($ref) && ctype_digit($part)) {
$index=(int)$part;
if ($last) { if (!empty($change['delete'])) { array_splice($ref,$index,1); } else $ref[$index]=$change['value'] ?? null; }
elseif (array_key_exists($index,$ref)) $ref=&$ref[$index]; else throw new Problem('Элемент массива не найден.');
} else throw new Problem('Путь проходит через скалярное поле.');
}
unset($ref);
}
self::assertWritable($copy);return $copy;
}
private static function adaptChange(\stdClass $config,array $change): array {
$path=$change['path']??[];if(($path[0]??null)!=='utm'||!isset($path[1]))return $change;
$aliases=['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
$key=$path[1];$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key]??$key;$settings=$config->utm??new \stdClass();$existing=[];
foreach($aliases[$canonical]??[$canonical] as $alias)if(isset($settings->$alias))$existing[]=$alias;
if(count($existing)>1)throw new Problem("Конфликт UTM-ключей {$canonical}. Перечитайте и явно устраните неоднозначность.");
$actual=$existing[0]??$canonical;$path[1]=$actual;$group=$settings->$actual??null;$map=$maps[$canonical]??[];$semantic=false;
if($group instanceof \stdClass){$semantic=in_array($group->default??null,array_values($map),true);foreach(get_object_vars($group->weights??new \stdClass()) as $variant=>$unused)if(in_array((string)$variant,array_values($map),true))$semantic=true;}
$convert=static function($value)use($map,$semantic){$original=$value;$digit=array_search((string)$value,$map,true);$digit=$digit===false?(string)$value:(string)$digit;return $semantic?($map[$digit]??$original):(is_int($original)?$original:$digit);};
if(($path[2]??null)==='weights'&&isset($path[3]))$path[3]=(string)$convert($path[3]);
if(array_key_exists('value',$change)){
if(($path[2]??null)==='default')$change['value']=$convert($change['value']);
if(count($path)===3&&$path[2]==='weights'&&$change['value'] instanceof \stdClass){$weights=new \stdClass();foreach(get_object_vars($change['value']) as $variant=>$weight){$variant=(string)$convert($variant);if(property_exists($weights,$variant))throw new Problem('Неоднозначные варианты весов.');$weights->$variant=$weight;}$change['value']=$weights;}
if(count($path)===2&&$change['value'] instanceof \stdClass){$value=self::decode(self::encode($change['value']));if(isset($value->default))$value->default=$convert($value->default);if(($value->weights??null)instanceof \stdClass){$weights=new \stdClass();foreach(get_object_vars($value->weights) as $variant=>$weight){$variant=(string)$convert($variant);if(property_exists($weights,$variant))throw new Problem('Неоднозначные варианты весов.');$weights->$variant=$weight;}$value->weights=$weights;}$change['value']=$value;}
}
$change['path']=$path;return $change;
}
public static function validate(\stdClass $config): array {
self::assertWritable($config);
PercentWeights::validate($config);
if (isset($config->host)) {
$host = $config->host;
if (!is_string($host) || !preg_match('~^(?:https://)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$~iD',$host) || str_contains($host,'..')) throw new Problem('Адрес сайта должен быть HTTPS-хостом без пути.');
}
if (isset($config->requisites) && (!is_array($config->requisites) || array_filter($config->requisites,static fn($line)=>!is_string($line)))) throw new Problem('Реквизиты должны содержать строки.');
$utm=$config->utm ?? new \stdClass();
if (!$utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
foreach (['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'] as $old=>$new) if (isset($utm->$old,$utm->$new)) throw new Problem("Конфликт старого {$old} и нового {$new} UTM-ключа.");
$maps=['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
$warnings=[];
foreach (get_object_vars($utm) as $key=>$group) {
$canonical=['registration'=>'reg','chob'=>'banch','bchoice'=>'banch'][$key] ?? $key;
if (!in_array($canonical,['indpic','theme','degr','reg','banch','tp','cons','focus','part'],true)) continue;
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights ?? null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$key}.");
$number=static function($value) use($maps,$canonical): string {
if (!is_int($value) && !is_string($value)) throw new Problem('Вариант UTM должен быть номером.');
$value=(string)$value;
if ($canonical==='part' && !in_array($value,['1','2'],true)) throw new Problem('Вариант part должен быть 1 или 2.');
if (isset($maps[$canonical])) { $digit=array_search($value,$maps[$canonical],true); if ($digit!==false) return (string)$digit; }
if (!preg_match('/^[1-9]$/D',$value)) throw new Problem('Вариант UTM должен быть 1–9.');
return $value;
};
$default=$number($group->default);$seen=[];
foreach (get_object_vars($group->weights) as $variant=>$weight) {
$digit=$number($variant);
if (isset($seen[$digit])) throw new Problem('Конфликт semantic и numeric вариантов UTM.'); $seen[$digit]=true;
if ((!is_int($weight) && !is_float($weight)) || !is_finite((float)$weight) || $weight<0) throw new Problem('Вес должен быть конечным неотрицательным числом.');
}
if (in_array($canonical,['indpic','degr','tp'],true)&&!isset($seen[$default])) throw new Problem("Добавьте вариант {$default} в таблицу весов {$key} (допустим вес 0).");
}
return $warnings;
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace PrelandAdmin;
final class Conflict extends Problem { public function __construct() { parent::__construct('Файл изменился. Перечитайте настройки и повторите изменение.',409); } }
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Применяет независимый именованный UTM-снимок, сохраняя остальные поля получателя. */
final class Copy
{
private const GROUPS = ['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
private const ALIASES = ['reg'=>['reg','registration'],'banch'=>['banch','chob','bchoice']];
private const MAPS = ['theme'=>['1'=>'dark','2'=>'light'],'reg'=>['1'=>'before','2'=>'after'],'banch'=>['1'=>'b','2'=>'a'],'focus'=>['1'=>'face','2'=>'center'],'part'=>['1'=>'1','2'=>'2']];
public function __construct(private Store $store) {}
public function create(array $data): array
{
if (count($data)!==2 || array_diff(array_keys($data),['preset','targets']) || !is_string($data['preset']??null)) throw new Problem('Передайте только ID набора и получателей копирования.');
$targets=$data['targets']??null;
if (!is_array($targets) || !array_is_list($targets) || !$targets || count($targets)>100) throw new Problem('Выберите от 1 до 100 получателей.');
$selected=[];$seen=[];
foreach ($targets as $entry) {
$target=$this->selection($entry);
if (isset($seen[$target['id']])) throw new Problem('Получатели не должны повторяться.');
$seen[$target['id']]=true;$this->available($target['id']);$selected[]=$target;
}
return $this->store->locked('utm-presets',function()use($data,$selected){
$preset=(new UtmPresets($this->store))->get($data['preset']);
$changes=self::project((object)['utm'=>$preset['utm']]);
if (!$changes) throw new Problem('В наборе нет UTM для копирования.');
// Jobs сериализует каждое значение отдельно: JSON-объекты и массивы сохраняют типы.
return (new Jobs($this->store))->create('copy',['preset'=>['id'=>$preset['id'],'name'=>$preset['name']],'targets'=>$selected,'changes'=>$changes]);
});
}
private function selection(mixed $value): array
{
if (!$value instanceof \stdClass) throw new Problem('Получатель должен быть объектом с ID и версией.');
$value=get_object_vars($value);
if (count($value)!==2 || array_diff(array_keys($value),['id','version']) || !is_string($value['id']??null) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$value['id']) || !is_string($value['version']??null) || !preg_match('/^[a-f0-9]{64}$/D',$value['version'])) throw new Problem('Укажите корректные ID и версию сохранённых настроек.');
return $value;
}
private function available(string $id): array
{
$target=$this->store->target($id);
if (empty($target['enabled'])) throw new Problem('Подключение отключено.');
if (empty($target['verified'])) throw new Problem('Сначала проверьте подключение.');
return $target;
}
/** Проверяет неоднозначность всех alias, включая пару chob/bchoice и значения null. */
public static function assertAliases(\stdClass $config): void
{
if (!property_exists($config,'utm')) return;
if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
foreach (self::ALIASES as $canonical=>$aliases) {
$found=0;foreach ($aliases as $alias) if (property_exists($config->utm,$alias)) $found++;
if ($found>1) throw new Problem("Конфликт UTM-ключей {$canonical}. Явно устраните неоднозначность.");
}
}
public static function project(\stdClass $config): array
{
self::assertAliases($config);
$projection=new \stdClass();$changes=[];
$projection->utm=new \stdClass();
foreach (self::GROUPS as $canonical) {
$group=null;$present=false;
foreach (self::ALIASES[$canonical]??[$canonical] as $alias) {
if (isset($config->utm) && property_exists($config->utm,$alias)) {$group=$config->utm->$alias;$present=true;break;}
}
// Отсутствующая группа источника никогда не удаляет группу получателя.
if (!$present) continue;
if (!$group instanceof \stdClass || !isset($group->default) || !(($group->weights??null) instanceof \stdClass)) throw new Problem("Некорректная структура UTM {$canonical} источника.");
$number=static function(mixed $value) use($canonical): mixed {
if (!is_string($value) && !is_int($value)) throw new Problem('Вариант UTM источника должен быть номером.');
$digit=array_search((string)$value,self::MAPS[$canonical]??[],true);
return $digit===false?$value:(string)$digit;
};
$weights=new \stdClass();
foreach (get_object_vars($group->weights) as $variant=>$weight) {
$variant=(string)$number($variant);
if (property_exists($weights,$variant)) throw new Problem('Конфликт semantic и numeric вариантов UTM источника.');
$weights->$variant=$weight;
}
$default=$number($group->default);
$projection->utm->$canonical=(object)['default'=>$default,'weights'=>$weights];
// Меняем листья, чтобы сохранить неизвестные свойства группы получателя.
$changes[]=['path'=>['utm',$canonical,'default'],'value'=>$default];
$changes[]=['path'=>['utm',$canonical,'weights'],'value'=>$weights];
}
// Редактор проверяет формат конфига, а не реализацию вариантов на сайтах.
Config::validate($projection);
return $changes;
}
}
+384
View File
@@ -0,0 +1,384 @@
<?php
namespace PrelandAdmin;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use ZipArchive;
/**
* Файловый контракт Set демонстрационных изображений Index.
* Перенесено из API 591a67cc8302 components/indpic/IndpicSetStorage.php (preland#22).
*
* Компонент не знает о БД: он только проверяет и атомарно заменяет содержимое
* каталогов `images/indpic/<номер>`. Это позволяет одинаково применять его к
* Set, добавленному через SFTP или ZIP CPanel.
*/
final class IndpicSetStorage
{
/** @var array<string, array{width: int, height: int}> */
private const DIMENSIONS = [
'01_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'01_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'01_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'01_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'01_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'01_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'01_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'01_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'01_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'01_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'01_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'01_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'01_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'01_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'01_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'01_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'02_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'02_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'02_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'02_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'02_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'02_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'02_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'02_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'02_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'02_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'02_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'02_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'02_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'02_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'02_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'02_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'03_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'03_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'03_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'03_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'03_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'03_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'03_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'03_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'03_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'03_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'03_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'03_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'03_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'03_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'03_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'03_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'04_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'04_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'04_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'04_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'04_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'04_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'04_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'04_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'04_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'04_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'04_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'04_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'04_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'04_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'04_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'04_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
'05_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
'05_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
'05_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
'05_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
'05_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
'05_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
'05_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
'05_mob_1-source.avif' => ['width' => 400, 'height' => 217],
'05_mob_1-source.webp' => ['width' => 400, 'height' => 217],
'05_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
'05_mob_2-result.avif' => ['width' => 400, 'height' => 217],
'05_mob_2-result.webp' => ['width' => 400, 'height' => 217],
'05_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
'05_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
'05_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
'05_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
];
private readonly ?\Closure $imageInspector;
public function __construct(private readonly string $root, ?callable $imageInspector = null)
{
$this->imageInspector = $imageInspector === null ? null : \Closure::fromCallable($imageInspector);
}
/** @return array<string, array{width: int, height: int}> */
public static function expectedDimensions(): array
{
return self::DIMENSIONS;
}
public function isComplete(int $set): bool
{
try {
$this->validateDirectory($set);
return true;
} catch (RuntimeException) {
return false;
}
}
/** @throws RuntimeException если каталог не соответствует контракту Set. */
public function validateDirectory(int $set): void
{
$directory = $this->directory($set);
if (!is_dir($directory)) {
throw new RuntimeException("Папка Set {$set} не найдена.");
}
$actual = [];
$errors = [];
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $item) {
if ($item->isLink() || !$item->isFile()) {
$errors[] = "Лишний элемент в Set {$set}: {$item->getFilename()}.";
continue;
}
$actual[$item->getFilename()] = $item->getPathname();
}
$expected = self::DIMENSIONS;
foreach (array_keys($expected) as $filename) {
if (!isset($actual[$filename])) {
$errors[] = "Отсутствует обязательный файл {$filename}.";
}
}
foreach (array_keys($actual) as $filename) {
if (!isset($expected[$filename])) {
$errors[] = "Лишний файл {$filename}.";
}
}
foreach ($expected as $filename => $dimensions) {
if (!isset($actual[$filename])) {
continue;
}
try {
$actualDimensions = $this->dimensions($actual[$filename]);
if ($actualDimensions !== $dimensions) {
$errors[] = sprintf(
'Неверный размер %s: %d×%d, ожидалось %d×%d.',
$filename,
$actualDimensions['width'],
$actualDimensions['height'],
$dimensions['width'],
$dimensions['height']
);
}
} catch (RuntimeException $exception) {
$errors[] = $exception->getMessage();
}
}
if ($errors !== []) {
throw new RuntimeException(implode("\n", $errors));
}
}
/**
* Полностью и атомарно заменяет Set распакованным ZIP после проверки всех файлов.
*
* @throws RuntimeException
*/
public function replaceFromZip(int $set, string $zipPath): void
{
$this->assertSetNumber($set);
if (!is_file($zipPath)) {
throw new RuntimeException('ZIP-файл не получен.');
}
if (!class_exists(ZipArchive::class)) {
throw new RuntimeException('На сервере не доступна поддержка ZIP.');
}
$stagingParent = $this->root . DIRECTORY_SEPARATOR . '.staging';
$staging = $stagingParent . DIRECTORY_SEPARATOR . $set . '-' . bin2hex(random_bytes(8));
$stagedSet = $staging . DIRECTORY_SEPARATOR . $set;
if (!is_dir($stagingParent) && !mkdir($stagingParent, 0775, true) && !is_dir($stagingParent)) {
throw new RuntimeException('Не удалось создать staging-каталог Set.');
}
if (!mkdir($stagedSet, 0775, true)) {
throw new RuntimeException('Не удалось подготовить staging-каталог Set.');
}
try {
$this->extractZip($zipPath, $set, $stagedSet);
$this->validateDirectoryPath($stagedSet, $set);
$this->activateStagedSet($set, $stagedSet);
} catch (\Throwable $exception) {
if (is_dir($staging)) {
$this->removeDirectory($staging);
}
throw $exception instanceof RuntimeException
? $exception
: new RuntimeException('Не удалось заменить Set ZIP-архивом.', 0, $exception);
}
if (is_dir($staging)) {
$this->removeDirectory($staging);
}
}
private function extractZip(string $zipPath, int $set, string $target): void
{
$zip = new ZipArchive();
if ($zip->open($zipPath) !== true) {
throw new RuntimeException('Не удалось открыть ZIP-архив.');
}
try {
$prefix = $set . '/';
$files = [];
$directories = [];
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = (string) $zip->getNameIndex($index);
if ($name === '' || str_contains($name, '\\') || str_starts_with($name, '/') || str_contains($name, '../')) {
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
}
if ($name === $prefix) {
$directories[] = $name;
continue;
}
if (!str_starts_with($name, $prefix) || str_contains(substr($name, strlen($prefix)), '/')) {
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
}
$filename = substr($name, strlen($prefix));
if (!isset(self::DIMENSIONS[$filename]) || isset($files[$filename])) {
throw new RuntimeException('ZIP содержит лишний или повторяющийся файл.');
}
$files[$filename] = $index;
}
// Некоторые ZIP-клиенты (в частности проводник Windows) не добавляют
// отдельную запись корневого каталога: достаточно 90 файлов с единым
// префиксом "<номер Set>/". Вложенные каталоги всё равно отклоняются выше.
if (count($directories) > 1 || count($files) !== count(self::DIMENSIONS)) {
throw new RuntimeException("ZIP должен содержать папку {$set} и ровно 90 файлов в ней.");
}
foreach (array_keys(self::DIMENSIONS) as $filename) {
if (!isset($files[$filename])) {
throw new RuntimeException("В ZIP отсутствует обязательный файл {$filename}.");
}
$stream = $zip->getStream($prefix . $filename);
if ($stream === false) {
throw new RuntimeException("Не удалось прочитать файл {$filename} из ZIP.");
}
$destination = $target . DIRECTORY_SEPARATOR . $filename;
$output = fopen($destination, 'wb');
if ($output === false) {
fclose($stream);
throw new RuntimeException("Не удалось подготовить файл {$filename}.");
}
$copied = stream_copy_to_stream($stream, $output, 8388609);
fclose($output);
fclose($stream);
if ($copied === false || $copied > 8388608) {
throw new RuntimeException("Не удалось распаковать файл {$filename}.");
}
chmod($destination, 0644);
}
} finally {
$zip->close();
}
}
private function activateStagedSet(int $set, string $stagedSet): void
{
$destination = $this->directory($set);
$backup = $this->root . DIRECTORY_SEPARATOR . '.backup-' . $set . '-' . bin2hex(random_bytes(8));
$hadDestination = is_dir($destination);
if ($hadDestination && !rename($destination, $backup)) {
throw new RuntimeException('Не удалось подготовить замену текущего Set.');
}
if (!rename($stagedSet, $destination)) {
if ($hadDestination) {
rename($backup, $destination);
}
throw new RuntimeException('Не удалось активировать новый Set.');
}
if ($hadDestination) {
$this->removeDirectory($backup);
}
}
/** @return array{width: int, height: int} */
private function dimensions(string $path): array
{
if ($this->imageInspector !== null) {
$dimensions = ($this->imageInspector)($path);
if (!is_array($dimensions) || !isset($dimensions['width'], $dimensions['height'])) {
throw new RuntimeException('Проверка изображения вернула некорректный размер.');
}
return ['width' => (int) $dimensions['width'], 'height' => (int) $dimensions['height']];
}
if (!class_exists(\Imagick::class)) {
throw new RuntimeException('На сервере не доступна проверка изображений Imagick.');
}
try {
$image = new \Imagick();
$image->readImage($path);
$format = strtoupper($image->getImageFormat());
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$allowed = ['jpg'=>['JPEG','JPG'], 'webp'=>['WEBP'], 'avif'=>['AVIF','HEIC']];
if (!in_array($format, $allowed[$extension] ?? [], true) || $image->getNumberImages() !== 1) {
throw new RuntimeException('Формат изображения не совпадает с расширением либо содержит несколько кадров.');
}
$dimensions = ['width' => $image->getImageWidth(), 'height' => $image->getImageHeight()];
$image->clear();
$image->destroy();
return $dimensions;
} catch (\Throwable $exception) {
throw new RuntimeException('Файл ' . basename($path) . ' не является корректным изображением.', 0, $exception);
}
}
private function validateDirectoryPath(string $directory, int $set): void
{
$storage = new self(dirname($directory), $this->imageInspector);
$storage->validateDirectory($set);
}
private function directory(int $set): string
{
$this->assertSetNumber($set);
return $this->root . DIRECTORY_SEPARATOR . $set;
}
private function assertSetNumber(int $set): void
{
if ($set < 1 || $set > 9) {
throw new RuntimeException('Номер Set должен быть от 1 до 9.');
}
}
private function removeDirectory(string $directory): void
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($directory);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Jobs
{
public function __construct(private Store $store) {}
public function create(string $type,array $payload): array {
if(!in_array($type,['apply','copy','restore','upload','check','register'],true))throw new Problem('Неизвестный тип задания.');
$targets=$payload['targets']??(isset($payload['target'])?[$payload['target']]:[]);if(!$targets||count($targets)>100)throw new Problem('Выберите от 1 до 100 подключений.');
$results=[];$seen=[];foreach($targets as $t){$id=is_array($t)?($t['id']??''):$t;if(!is_string($id)||!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id))throw new Problem('Некорректная цель задания.');if(isset($seen[$id]))continue;$seen[$id]=true;$results[]=['target'=>$id,'status'=>'queued','message'=>'Ожидает выполнения.'];}
if(isset($payload['changes']))foreach($payload['changes'] as &$change){if(array_key_exists('value',$change)){$change['value_json']=json_encode($change['value'],JSON_THROW_ON_ERROR);unset($change['value']);}}unset($change);
$id=bin2hex(random_bytes(16));$job=['id'=>$id,'type'=>$type,'status'=>'queued','created_at'=>gmdate(DATE_ATOM),'results'=>$results,'payload'=>$payload];$this->store->write('jobs/'.$id.'.json',$job);return $this->public($job);
}
private function public(array $job): array {$job['changes']=array_map(static fn($c)=>['path'=>$c['path']],$job['payload']['changes']??[]);unset($job['payload']);foreach($job['results'] as &$result)unset($result['desired_hash'],$result['source_manifest'],$result['desired_manifest']);return $job;}
public function get(string $id): array {$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job)throw new Problem('Задание не найдено.',404);return $this->public($job);}
public function all(): array {$jobs=[];foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path)$jobs[]=$this->public($this->store->read('jobs/'.basename($path)));usort($jobs,static fn($a,$b)=>strcmp($b['created_at'],$a['created_at']));return array_slice($jobs,0,100);}
public function retry(string $id,array $targets): array {
return $this->store->locked('job-'.$id,function() use($id,$targets){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job)throw new Problem('Задание не найдено.',404);if($job['status']!=='completed')throw new Problem('Задание ещё выполняется.',409);
$count=0;foreach($job['results'] as &$result)if(in_array($result['target'],$targets,true)&&in_array($result['status'],['error','unknown'],true)){$result['status']='queued';$result['message']='Повтор ожидает выполнения.';$count++;}unset($result);
if(!$count)throw new Problem('Выберите цели с ошибкой либо неизвестным результатом. Конфликт требует перечитать конфиг.');$job['status']='queued';$this->store->write('jobs/'.$id.'.json',$job);return $this->public($job);
});
}
public function run(string $id): void {
$this->store->locked('job-'.$id,function() use($id){
$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;
$save=function() use(&$job,$id){$this->store->write('jobs/'.$id.'.json',$job);};$job['status']='running';$save();
foreach($job['results'] as $index=>$result){
if(!in_array($result['status'],['queued','running'],true))continue;
$recover=$result['status']==='running';$job['results'][$index]['status']='running';$save();
try{
$target=$this->store->target($result['target']);if(empty($target['enabled']))throw new Problem('Подключение отключено.');
if(empty($target['verified']))throw new Problem('Сначала проверьте подключение.');
$transport=new Transport($this->store,$target);$p=$job['payload'];$type=$job['type'];
if(in_array($type,['apply','copy','restore'],true)){
$current=$transport->readConfig();$expected=$p['version']??'';
if(in_array($type,['apply','copy'],true))foreach($p['targets'] as $selection)if($selection['id']===$target['id'])$expected=$selection['version']??'';
// После обрыва/рестарта сперва сверяем ожидаемый хеш уже подготовленного контента.
$desired=$job['results'][$index]['desired_hash']??null;
if($desired && hash_equals($desired,$current['version'])) {$job['results'][$index]=['target'=>$target['id'],'status'=>'success','message'=>'Сохранённое состояние подтверждено после повторного чтения.','version'=>$current['version']];$save();continue;}
if(!is_string($expected)||!hash_equals($current['version'],$expected))throw new Conflict();
if(isset($p['changes']))foreach($p['changes'] as &$change){if(isset($change['value_json']))$change['value']=json_decode($change['value_json'],false,64,JSON_THROW_ON_ERROR);}unset($change);
if($type==='copy')Copy::assertAliases($current['config']);
$next=$type==='restore'?Config::decode($this->store->backupContent($target['id'],$p['id'])):Config::patch($current['config'],$p['changes']??[]);
$warnings=Config::validate($next);
$content=Config::encode($next);$job['results'][$index]['desired_hash']=hash('sha256',$content);$save();
$written=$transport->writeConfig($expected,$content);$out=['message'=>'Настройки сохранены и проверены.','version'=>$written['version'],'warnings'=>$warnings];
}else{
$sets=new Sets($this->store);$number=Sets::number($p['number']??0);
if($type==='upload') {
$actual=$sets->manifest($target,$number);$saved=$job['results'][$index];
if(array_key_exists('desired_manifest',$saved)){
if($actual===$saved['desired_manifest']){
$out=$sets->check($target,$number,true,$p['name']??'');
$out['message']='Опубликованный набор подтверждён после повторного чтения.';
}elseif($actual!==($saved['source_manifest']??null))throw new Conflict();
else $out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$saved['source_manifest']??null,true);
}else{
$job['results'][$index]['source_manifest']=$actual;$job['results'][$index]['desired_manifest']=Sets::preparedManifest($p['prepared'],$number);$save();
$out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$actual,true);
}
}
else $out=$sets->check($target,$number,$type==='register',$p['name']??'');
}
$job['results'][$index]=['target'=>$target['id'],'status'=>'success']+$out;
}catch(Conflict $e){$job['results'][$index]=['target'=>$result['target'],'status'=>'conflict','message'=>$e->getMessage()];}
catch(\Throwable $e){$old=$job['results'][$index];$status=$e instanceof Problem&&$e->status===504?'unknown':'error';$job['results'][$index]=['target'=>$result['target'],'status'=>$status,'message'=>$e instanceof Problem?$e->getMessage():'Операция не выполнена. Проверьте доступность и повторите.'];foreach(['desired_hash','desired_manifest','source_manifest'] as $field)if(array_key_exists($field,$old))$job['results'][$index][$field]=$old[$field];}
$this->store->audit(['job'=>$id,'target'=>$result['target'],'type'=>$job['type'],'paths'=>array_column($job['payload']['changes']??[],'path'),'status'=>$job['results'][$index]['status']]);$save();
}
$job['status']='completed';$job['finished_at']=gmdate(DATE_ATOM);$save();
});
}
public function tick(): void {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))$this->run($job['id']);}}
public function pending(): ?string {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))return $job['id'];}return null;}
public function interrupted(string $id): void {
$this->store->locked('job-'.$id,function()use($id){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;$queued=false;
foreach($job['results'] as &$result){
if($result['status']==='running'){
$message='Исполнитель прервался. Повтор сначала перечитает фактическое состояние цели.';
if($job['type']==='upload'){
// Read выполняет journal recovery под транспортной блокировкой без Imagick.
// Supervisor обязан попытаться вернуть публичный путь до ожидания retry пользователя.
try{(new Transport($this->store,$this->store->target($result['target'])))->readConfig();$result['recovery_checked']=true;}
catch(\Throwable){$result['recovery_checked']=false;$message='Исполнитель прервался. Автоматическое восстановление не подтверждено: цель недоступна. Повтор сначала перечитает фактическое состояние.';}
}
$result['status']='unknown';$result['message']=$message;
}
if($result['status']==='queued')$queued=true;
}unset($result);
$job['status']=$queued?'queued':'completed';if(!$queued)$job['finished_at']=gmdate(DATE_ATOM);$this->store->write('jobs/'.$id.'.json',$job);
$this->store->audit(['job'=>$id,'status'=>'interrupted']);
});
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Независимый выбор меток: читает реестр целей, пишет только приватное состояние ссылки. */
final class LinkBuilder
{
private const KEYS = ['indpic', 'theme', 'degr', 'reg', 'banch', 'tp', 'cons', 'focus', 'part'];
public function __construct(private readonly Store $store) {}
public function get(string $targetId): array
{
$this->assertId($targetId);
return $this->store->locked('link-builder-' . $targetId, function () use ($targetId): array {
$origin = $this->origin($this->store->target($targetId));
$name = $this->filename($targetId);
$saved = $this->store->read($name, null);
if ($saved === null) return ['selection'=>null, 'url'=>null, 'saved_at'=>null];
if (!is_array($saved) || !is_array($saved['selection'] ?? null)
|| !is_string($saved['url'] ?? null) || !is_string($saved['saved_at'] ?? null)) {
throw new Problem('Повреждено сохранённое состояние конструктора ссылки.', 503);
}
// Старые восемь полей дополняем part=1, сохраняя исходный шум остальных меток.
$legacy = count($saved['selection']) === 8 && !array_key_exists('part', $saved['selection']);
$selection = $this->selection($legacy ? $saved['selection'] + ['part'=>'1'] : $saved['selection']);
$query = parse_url($saved['url'], PHP_URL_QUERY);
if (!is_string($query)) throw new Problem('Повреждена сохранённая ссылка.', 503);
parse_str($query, $parameters);
if ($legacy && array_keys($parameters) === array_slice(self::KEYS, 0, 8)) {
$parameters['part'] = bin2hex(random_bytes(2)) . '1' . bin2hex(random_bytes(6));
}
if (array_keys($parameters) !== self::KEYS) throw new Problem('Повреждена сохранённая ссылка.', 503);
foreach ($selection as $key=>$value) {
$digit = $value === 'rnd' ? '0' : $value;
if (!is_string($parameters[$key]) || !preg_match('/^[a-f0-9]{4}' . $digit . '[a-f0-9]{12}$/D', $parameters[$key])) {
throw new Problem('Повреждена сохранённая ссылка.', 503);
}
}
// Переименование цели меняет только origin, сохранённый шум не генерируется заново.
$url = $origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
if ($saved['url'] !== $url || $legacy) {
$saved['selection'] = $selection;
$saved['url'] = $url;
$this->store->write($name, $saved);
}
return ['selection'=>$selection, 'url'=>$url, 'saved_at'=>$saved['saved_at']];
});
}
public function save(string $targetId, array $selection): array
{
$this->assertId($targetId);
$selection = $this->selection($selection);
return $this->store->locked('link-builder-' . $targetId, function () use ($targetId, $selection): array {
$origin = $this->origin($this->store->target($targetId));
$parameters = [];
foreach ($selection as $key=>$value) {
$parameters[$key] = bin2hex(random_bytes(2)) . ($value === 'rnd' ? '0' : $value) . bin2hex(random_bytes(6));
}
$saved = [
'selection'=>$selection,
'url'=>$origin . '/?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986),
'saved_at'=>gmdate(DATE_ATOM),
];
$this->store->write($this->filename($targetId), $saved);
return $saved;
});
}
private function selection(array $input): array
{
if (count($input) !== count(self::KEYS) || array_diff(array_keys($input), self::KEYS)) {
throw new Problem('Передайте ровно девять параметров конструктора ссылки.');
}
$selection = [];
foreach (self::KEYS as $key) {
$value = $input[$key] ?? null;
if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value))
|| ($key === 'part' && !in_array($value, ['1', '2', 'rnd'], true))) {
throw new Problem("Недопустимое значение параметра {$key} конструктора ссылки.");
}
$selection[$key] = $value;
}
return $selection;
}
private function origin(array $target): string
{
$domain = $target['domain'] ?? null;
if (!is_string($domain) || strlen($domain)>253 || !preg_match('/^[a-z0-9.-]+$/iD', $domain)
|| str_starts_with($domain, '.') || str_ends_with($domain, '.')
|| filter_var($domain, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false) {
throw new Problem('В подключении указан небезопасный домен для HTTPS-ссылки.');
}
return 'https://' . strtolower($domain);
}
private function assertId(string $targetId): void
{
if (!preg_match('/^[a-zA-Z0-9_-]{1,64}$/D', $targetId)) throw new Problem('Некорректный ID подключения.');
}
private function filename(string $targetId): string
{
return 'link-builder-' . $targetId . '.json';
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Notes
{
private const MARKERS=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
public function __construct(private readonly Store $store) {}
public function all(): array {
return $this->store->locked('utm-notes',fn()=>$this->load());
}
public function save(array $input): array {
if(count($input)!==3||array_diff(array_keys($input),['marker','text','version'])||!is_string($input['marker']??null)||!in_array($input['marker'],self::MARKERS,true)||!is_string($input['text']??null)||!is_string($input['version']??null))throw new Problem('Передайте маркер UTM, текст и версию заметки.');
if(!self::validText($input['text']))throw new Problem('Заметка должна содержать корректный UTF-8, не более 10000 символов, без управляющих символов кроме перевода строки и табуляции.');
return $this->store->locked('utm-notes',function() use($input){
$notes=$this->load();$marker=$input['marker'];
if(!hash_equals($notes[$marker]['version'],$input['version']))throw new Problem('Заметка уже изменена. Загрузите актуальную версию перед сохранением.',409);
$note=['text'=>$input['text'],'version'=>bin2hex(random_bytes(32)),'saved_at'=>gmdate(DATE_ATOM)];
$notes[$marker]=$note;$this->store->write('utm-notes.json',$notes);return $note;
});
}
private function load(): array {
$path=$this->store->path('utm-notes.json');clearstatcache(true,$path);
if(is_link($path)||file_exists($path)&&(!is_file($path)||filesize($path)>1048576))throw new Problem('Повреждено приватное состояние заметок.',503);
if(!file_exists($path)){
$notes=[];foreach(self::MARKERS as $marker)$notes[$marker]=['text'=>'','version'=>hash('sha256','utm-notes:'.$marker.':empty'),'saved_at'=>null];return $notes;
}
$notes=$this->store->read('utm-notes.json',null);
// Старое хранилище без part читаем с пустой заметкой; прежние версии сохраняем.
if(is_array($notes)&&count($notes)===8&&!array_key_exists('part',$notes))$notes['part']=['text'=>'','version'=>hash('sha256','utm-notes:part:empty'),'saved_at'=>null];
if(!is_array($notes)||count($notes)!==count(self::MARKERS)||array_diff(self::MARKERS,array_keys($notes)))throw new Problem('Повреждено приватное состояние заметок.',503);
$ordered=[];
foreach(self::MARKERS as $marker){
$note=$notes[$marker];
if(!is_array($note)||count($note)!==3||array_diff(array_keys($note),['text','version','saved_at'])||!is_string($note['text']??null)||!self::validText($note['text'])||!is_string($note['version']??null)||!preg_match('/^[a-f0-9]{64}$/D',$note['version'])||!array_key_exists('saved_at',$note)||($note['saved_at']!==null&&(!is_string($note['saved_at'])||!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$note['saved_at']))))throw new Problem('Повреждено приватное состояние заметок.',503);
if($note['saved_at']===null&&($note['text']!==''||$note['version']!==hash('sha256','utm-notes:'.$marker.':empty')))throw new Problem('Повреждено приватное состояние заметок.',503);
$ordered[$marker]=$note;
}
return $ordered;
}
private static function validText(string $text): bool {
return strlen($text)<=40000&&preg_match('//u',$text)===1&&preg_match('/[\x{0000}-\x{0008}\x{000B}\x{000C}\x{000E}-\x{001F}\x{007F}-\x{009F}]/u',$text)===0&&preg_match_all('/./us',$text)<=10000;
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Явная миграция #28: заменяет исключительно числовые токены известных весов. */
final class PercentMigration
{
private const KEYS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part'];
public static function patch(string $raw): string
{
$config=Config::decode($raw);
$offset=0;
$tree=self::node($raw,$offset);
$utm=self::property($tree,'utm');
if($utm===null||$utm['type']!=='object')throw new Problem('Миграция: отсутствует объект utm.');
foreach([['reg','registration'],['banch','chob','bchoice']] as $aliases){
$present=array_filter($aliases,static fn($key)=>self::property($utm,$key)!==null);
if(count($present)>1)throw new Problem('Миграция: конфликт UTM-псевдонимов.');
}
// Запрещаем потерю положительных значений при json_decode (underflow).
foreach($utm['properties'] as $key=>$setting){
if(!in_array((string)$key,self::KEYS,true))continue;
$weights=self::property($setting,'weights');
if($weights===null||$weights['type']!=='object')throw new Problem('Миграция: отсутствует объект весов '.$key.'.');
foreach($weights['properties'] as $variant=>$node){
$token=substr($raw,$node['start'],$node['end']-$node['start']);
if($node['type']!=='number')throw new Problem('Миграция: вес должен быть числом.');
$number=json_decode($token,false,64,JSON_THROW_ON_ERROR);
if(!is_finite((float)$number)||((float)$number===0.0&&preg_match('/[1-9]/',explode('e',strtolower($token))[0])))throw new Problem('Миграция: вес вне безопасного числового диапазона.');
}
}
$normalized=PercentWeights::normalizeRelative($config);
PercentWeights::validate($normalized);
$replacements=[];
foreach($utm['properties'] as $key=>$setting){
if(!in_array((string)$key,self::KEYS,true))continue;
$weights=self::property($setting,'weights');
foreach($weights['properties'] as $variant=>$node){
$value=$normalized->utm->$key->weights->$variant;
$old=$config->utm->$key->weights->$variant;
if((float)$old===(float)$value)continue;
$text=json_encode($value,JSON_THROW_ON_ERROR);
$replacements[]=[$node['start'],$node['end'],$text];
}
}
usort($replacements,static fn($a,$b)=>$b[0]<=>$a[0]);
foreach($replacements as [$start,$end,$text])$raw=substr_replace($raw,$text,$start,$end-$start);
PercentWeights::validate(Config::decode($raw));
return $raw;
}
/** Грамматика предварительно проверена Config::decode; диапазоны аналогичны ensure-utm-config.php. */
private static function node(string $raw,int &$offset): array
{
$length=strlen($raw);
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
$start=$offset;$char=$raw[$offset]??'';
if($char==='"'){
$offset++;
while($offset<$length){if($raw[$offset]==='\\'){$offset+=2;continue;}if($raw[$offset++]==='"')break;}
return ['start'=>$start,'end'=>$offset,'type'=>'string','value'=>json_decode(substr($raw,$start,$offset-$start),true,64,JSON_THROW_ON_ERROR)];
}
if($char==='{'||$char==='['){
$object=$char==='{';$close=$object?'}':']';$offset++;$properties=[];
while(true){
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
if(($raw[$offset]??'')===$close){$offset++;break;}
if($object){
$key=self::node($raw,$offset)['value'];
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
$offset++;
if(array_key_exists($key,$properties))throw new Problem('Миграция: повторный JSON-ключ; запись запрещена.');
$properties[$key]=self::node($raw,$offset);
}else{self::node($raw,$offset);}
while($offset<$length&&ctype_space($raw[$offset]))$offset++;
if(($raw[$offset]??'')===','){$offset++;continue;}
$offset++;break;
}
return ['start'=>$start,'end'=>$offset,'type'=>$object?'object':'array','properties'=>$properties];
}
while($offset<$length&&!str_contains(",]} \t\r\n",$raw[$offset]))$offset++;
return ['start'=>$start,'end'=>$offset,'type'=>preg_match('/^-?[0-9]/',substr($raw,$start,$offset-$start))?'number':'scalar'];
}
private static function property(array $node,string $key): ?array {return $node['properties'][$key]??null;}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Контракт хранимых процентов. Миграция вызывается только явно, отдельно от чтения и записи. */
final class PercentWeights
{
public const GROUPS=['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part'];
private static function groups(\stdClass $config): array
{
if (!property_exists($config,'utm')) return [];
if (!$config->utm instanceof \stdClass) throw new Problem('UTM должен быть объектом.');
$groups=[];
foreach (get_object_vars($config->utm) as $key=>$group) {
if (!in_array($key,self::GROUPS,true)) continue;
if (!$group instanceof \stdClass || !property_exists($group,'weights') || !$group->weights instanceof \stdClass) throw new Problem("Веса UTM {$key} должны быть объектом процентов.");
$groups[$key]=$group;
}
return $groups;
}
private static function number(mixed $value,string $key): float
{
if ((!is_int($value) && !is_float($value)) || !is_finite((float)$value) || $value<0) throw new Problem("Вес UTM {$key} должен быть конечным неотрицательным числом процентов.");
return (float)$value;
}
/** Допуск покрывает только погрешность двоичного float при переводе в сотые процента. */
private static function basisPoints(float $value,string $key): int
{
if ($value!==round($value,2)) throw new Problem("Вес UTM {$key} должен содержать не более двух знаков после запятой.");
$scaled=$value*100;$rounded=round($scaled);
if (($value>0 && $rounded===0.0) || abs($scaled-$rounded)>PHP_FLOAT_EPSILON*max(1.0,abs($scaled))*8) throw new Problem("Вес UTM {$key} должен содержать не более двух знаков после запятой; точные доли должны представляться с шагом 0,01%.");
return (int)$rounded;
}
public static function validate(\stdClass $config): void
{
foreach (self::groups($config) as $key=>$group) {
$sum=0;
foreach (get_object_vars($group->weights) as $weight) {
$value=self::number($weight,$key);
if ($value>100) throw new Problem("Вес UTM {$key} должен быть от 0 до 100%.");
$sum+=self::basisPoints($value,$key);
if ($sum>10000) throw new Problem("Сумма весов UTM {$key} должна быть 100% либо все веса — 0%.");
}
if ($sum!==0 && $sum!==10000) throw new Problem("Сумма весов UTM {$key} должна быть 100% либо все веса — 0%. Старые относительные веса требуют явной миграции.");
}
}
/** Только для явной миграции: округляем каждую долю, остаток не перераспределяем. */
public static function normalizeRelative(\stdClass $config): \stdClass
{
// Клонирование не через JSON: сохраняем типы чисел и проверяем INF/NAN сами.
$clone=static function(mixed $value) use (&$clone): mixed {
if ($value instanceof \stdClass) {$copy=new \stdClass();foreach (get_object_vars($value) as $key=>$item) $copy->$key=$clone($item);return $copy;}
if (is_array($value)) {foreach ($value as $key=>$item) $value[$key]=$clone($item);}
return $value;
};
$copy=$clone($config);
foreach (self::groups($copy) as $key=>$group) {
$values=[];$max=0.0;
foreach (get_object_vars($group->weights) as $variant=>$weight) {$values[$variant]=self::number($weight,$key);$max=max($max,$values[$variant]);}
if ($max===0.0) continue;
$sum=0.0;foreach ($values as $weight) $sum+=$weight/$max;
foreach ($values as $variant=>$weight) {
$percent=($weight/$max)/$sum*100;
if ($weight>0 && $percent===0.0) throw new Problem("Доля UTM {$key} слишком мала для точного представления с шагом 0,01%.");
$points=(int)round($percent*100);
$group->weights->{(string)$variant}=$points%100===0?intdiv($points,100):$points/100;
}
}
self::validate($copy);
return $copy;
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
namespace PrelandAdmin;
class Problem extends \RuntimeException { public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); } }
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Самодостаточный обработчик: один и тот же CAS выполняется локально и под SSH. */
final class Remote
{
private static function cleanStage(string $directory): void {if(!is_dir($directory)||is_link($directory))return;foreach(new \FilesystemIterator($directory,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isFile()||$item->isLink())unlink($item->getPathname());else throw new \RuntimeException('Небезопасный элемент staging.');}rmdir($directory);}
private static function journal(string $path,array $data): void {
$temp=$path.'.tmp-'.bin2hex(random_bytes(8));$fp=fopen($temp,'xb');if(!$fp)throw new \RuntimeException('Не удалось записать журнал переключения.');
try{$json=json_encode($data,JSON_THROW_ON_ERROR);if(fwrite($fp,$json)!==strlen($json)||!fflush($fp)||!fsync($fp))throw new \RuntimeException('Не удалось зафиксировать журнал переключения.');}finally{fclose($fp);}
if(!rename($temp,$path))throw new \RuntimeException('Не удалось опубликовать журнал переключения.');
}
private static function recover(string $root,string $private,bool $local): void {
foreach(glob($private.'/swap-[1-9].json')?:[] as $path){$record=json_decode(file_get_contents($path),true);$number=(int)($record['number']??0);if($number<1||$number>9)throw new \RuntimeException('Повреждён журнал переключения набора.');
$parent=$local?$root.'/.preland-admin-staging':$private;$stage=$record['stage']??'';$backup=$record['backup']??'';
if(dirname($stage)!==$parent||dirname($backup)!==$parent||!preg_match('/^set-[a-f0-9]{16}$/D',basename($stage))||!preg_match('/^backup-'.$number.'-[a-f0-9]{16}$/D',basename($backup)))throw new \RuntimeException('Небезопасные пути журнала переключения.');
$active=$root.'/'.$number;
if(!file_exists($active)&&!is_link($active)){
if(is_dir($backup)&&!is_link($backup)){if(!rename($backup,$active))throw new \RuntimeException('Не удалось восстановить предыдущий набор.');}
elseif(is_dir($stage)&&self::manifest($stage)===($record['desired_manifest']??null)){if(!chmod($stage,0755)||!rename($stage,$active))throw new \RuntimeException('Не удалось завершить переключение набора.');}
else throw new \RuntimeException('Незавершённый набор требует восстановления из резерва.');
}
// При наличии активного каталога третью версию не перезаписываем.
if(is_link($active)||!is_dir($active))throw new \RuntimeException('Небезопасный активный набор при восстановлении.');
self::cleanStage($stage);if($local)self::cleanStage($backup);unlink($path);
}
}
public static function manifest(string $dir): ?string {
if(!is_dir($dir)){if(file_exists($dir)||is_link($dir))throw new \RuntimeException('Путь набора занят небезопасным элементом.');return null;}
if(is_link($dir))throw new \RuntimeException('Символические ссылки набора запрещены.');$hashes=[];$total=0;
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||$item->getSize()>8388608||count($hashes)>=1000)throw new \RuntimeException('Небезопасный элемент набора.');$total+=$item->getSize();if($total>67108864)throw new \RuntimeException('Превышен лимит набора.');$hashes[$item->getFilename()]=hash_file('sha256',$item->getPathname());}
ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));
}
public static function execute(array $p): array {
// Этот обработчик исполняется отдельно по SSH, без автозагрузки Config.
// Отказ до любых приватных каталогов, recovery, резервов и temp-файлов.
if(($p['op']??null)==='write'&&is_string($p['content']??null)){
$candidate=json_decode($p['content']);
if($candidate instanceof \stdClass&&($candidate->utm??null) instanceof \stdClass&&property_exists($candidate->utm,'chb'))return ['ok'=>false,'error'=>'Устаревший ключ utm.chb нельзя записывать. Явно удалите его перед сохранением; восстановление такого архива запрещено.'];
}
$root=$p['root'];
if (!is_string($root)||!str_starts_with($root,'/')||str_contains($root,'..')||str_contains($root,'\\')||realpath($root)!==$root||is_link($root)) return ['ok'=>false,'error'=>'Небезопасный корневой путь.'];
$private=$p['local_private']??dirname($root).'/.preland-admin-'.substr(hash('sha256',$root),0,16);
if (is_link($private)) return ['ok'=>false,'error'=>'Небезопасный приватный каталог.'];
if (!is_dir($private) && !mkdir($private,0700)) return ['ok'=>false,'error'=>'Не удалось подготовить приватный каталог вне webroot.'];
chmod($private,0700);
$lock=fopen($private.'/publish.lock','c');if(!$lock) return ['ok'=>false,'error'=>'Не удалось получить удалённую блокировку.'];
$deadline=microtime(true)+20;while(!flock($lock,LOCK_EX|LOCK_NB)){if(microtime(true)>$deadline){fclose($lock);return ['ok'=>false,'error'=>'Истекло время ожидания блокировки.'];}usleep(50000);}
try {
self::recover($root,$private,isset($p['local_private']));
$config=$root.'/config.json';
if (is_link($config)||!is_file($config)||filesize($config)>2097152) throw new \RuntimeException('Настройки отсутствуют либо путь небезопасен.');
$content=file_get_contents($config);$version=hash('sha256',$content);
$op=$p['op'];
if ($op==='read') return ['ok'=>true,'content'=>$content,'version'=>$version];
if ($op==='test') {
if(!function_exists('fsync'))throw new \RuntimeException('Для безопасной публикации требуется PHP CLI не ниже 8.1 с fsync.');
if(!is_writable($root)||!is_writable($config)) throw new \RuntimeException('Нет прав записи.');
if(!json_decode($content) instanceof \stdClass)throw new \RuntimeException('config.json не является корректным объектом.');
$probe=$root.'/.config-probe-'.bin2hex(random_bytes(8));$renamed=$probe.'-renamed';
try{if(file_put_contents($probe,'preland-admin-probe')===false||!rename($probe,$renamed)||file_get_contents($renamed)!=='preland-admin-probe')throw new \RuntimeException('Атомарная запись недоступна.');}finally{if(is_file($probe))unlink($probe);if(is_file($renamed))unlink($renamed);}
return ['ok'=>true,'version'=>$version];
}
if ($op==='set-list') {$numbers=[];foreach(range(1,9) as $n)if(is_dir($root.'/'.$n)&&!is_link($root.'/'.$n))$numbers[]=$n;return ['ok'=>true,'numbers'=>$numbers];}
if ($op==='write') {
if (!hash_equals($version,(string)$p['version'])) return ['ok'=>false,'conflict'=>true,'error'=>'Версия изменена.'];
$next=$p['content'];$decoded=json_decode($next);if(!$decoded instanceof \stdClass || strlen($next)>2097152) throw new \RuntimeException('Некорректные настройки.');
$temp=$root.'/.config-'.bin2hex(random_bytes(8));$backup=$private.'/config-'.gmdate('YmdHis').'-'.bin2hex(random_bytes(6)).'.json';
if(file_put_contents($backup,$content)===false) throw new \RuntimeException('Не удалось сохранить резерв.');chmod($backup,0600);
if(file_put_contents($temp,$next)===false) throw new \RuntimeException('Не удалось записать временный файл.');chmod($temp,0644);
if(hash_file('sha256',$temp)!==hash('sha256',$next)||!rename($temp,$config)){@unlink($temp);throw new \RuntimeException('Не удалось атомарно заменить настройки.');}
$actual=hash_file('sha256',$config);if($actual!==hash('sha256',$next)) throw new \RuntimeException('Повторная проверка настроек не совпала.');
return ['ok'=>true,'version'=>$actual,'previous'=>$content];
}
$number=(int)($p['number']??0);if($number<1||$number>9) throw new \RuntimeException('Номер набора должен быть 1–9.');
$dir=$root.'/'.$number;if(is_link($dir)) throw new \RuntimeException('Символические ссылки набора запрещены.');
if($op==='set-manifest')return ['ok'=>true,'manifest'=>self::manifest($dir)];
if($op==='set-read') {
if(!is_dir($dir)) throw new \RuntimeException('Папка набора отсутствует.');$files=[];$bytes=0;
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if(!$item->isFile()||$item->isLink()||$item->getSize()>8388608) throw new \RuntimeException('Недопустимый файл набора (лимит 8 МиБ).');$bytes+=$item->getSize();if($bytes>67108864||count($files)>=90) throw new \RuntimeException('Превышен лимит набора (64 МиБ, 90 файлов).');$files[$item->getFilename()]=base64_encode(file_get_contents($item->getPathname()));}
return ['ok'=>true,'files'=>$files];
}
if($op==='set-write') {
if(!array_key_exists('expected_manifest',$p))throw new \RuntimeException('Отсутствует исходная версия набора.');
if(self::manifest($dir)!==$p['expected_manifest'])return ['ok'=>false,'conflict'=>true,'error'=>'Набор изменился. Перечитайте его состояние перед повторной публикацией.'];
$stageParent=isset($p['local_private'])?$root.'/.preland-admin-staging':$private;
if(is_link($stageParent))throw new \RuntimeException('Небезопасный staging-каталог.');
if(!is_dir($stageParent)&&!mkdir($stageParent,0700))throw new \RuntimeException('Не удалось подготовить staging.');
if(isset($p['local_private']))file_put_contents($stageParent.'/.htaccess',"Require all denied\n");
$stage=$stageParent.'/set-'.bin2hex(random_bytes(8));if(!mkdir($stage,0755))throw new \RuntimeException('Не удалось подготовить набор.');$backup=$stageParent.'/backup-'.$number.'-'.bin2hex(random_bytes(8));$moved=false;
try {
if(count($p['files'])!==90) throw new \RuntimeException('Требуется ровно 90 файлов.');$total=0;
foreach($p['files'] as $name=>$file){if(!preg_match('/^[0-9]{2}_(desk|mob)_[123]-(source|result|thumb)\.(jpg|webp|avif)$/D',$name))throw new \RuntimeException('Недопустимое имя файла.');$data=base64_decode($file['data'],true);$total+=strlen($data===false?'':$data);if($data===false||strlen($data)>8388608||$total>67108864||hash('sha256',$data)!==$file['sha256'])throw new \RuntimeException('Не совпала проверка файла либо превышен лимит.');$path=$stage.'/'.$name;if(file_put_contents($path,$data)===false||hash_file('sha256',$path)!==$file['sha256'])throw new \RuntimeException('Ошибка доставки файла.');chmod($path,0644);}
if(!chmod($stage,0755))throw new \RuntimeException('Не удалось открыть чтение подготовленного набора.');
if(is_dir($dir)){
// Резерв локального bind mount копируется в state до смены активного каталога.
if(isset($p['local_private'])){
$saved=$private.'/backup-'.$number.'-'.bin2hex(random_bytes(8));if(!mkdir($saved,0700))throw new \RuntimeException('Не удалось подготовить резерв.');
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||!copy($item->getPathname(),$saved.'/'.$item->getFilename()))throw new \RuntimeException('Не удалось сохранить резерв набора.');}
}
self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
if(!rename($dir,$backup))throw new \RuntimeException('Не удалось сохранить старый набор.');$moved=true;
}
else self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
if(!rename($stage,$dir)) {if($moved)rename($backup,$dir);throw new \RuntimeException('Не удалось активировать набор.');}
foreach($p['files'] as $name=>$file)if(hash_file('sha256',$dir.'/'.$name)!==$file['sha256']) {rename($dir,$stage);if($moved)rename($backup,$dir);throw new \RuntimeException('Ошибка проверки после публикации; выполнен откат.');}
if($moved && isset($p['local_private'])) {
foreach(new \FilesystemIterator($backup,\FilesystemIterator::SKIP_DOTS) as $item)unlink($item->getPathname());rmdir($backup);
}
unlink($private.'/swap-'.$number.'.json');
return ['ok'=>true,'hash'=>hash('sha256',json_encode(array_map(static fn($f)=>$f['sha256'],$p['files'])))];
} finally {if(is_dir($stage)){foreach(new \FilesystemIterator($stage,\FilesystemIterator::SKIP_DOTS) as $item)if($item->isFile())unlink($item->getPathname());rmdir($stage);}}
}
throw new \RuntimeException('Неизвестная операция.');
} catch (\Throwable $e) {return ['ok'=>false,'error'=>$e->getMessage()];} finally {flock($lock,LOCK_UN);fclose($lock);}
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Sets
{
public function __construct(private Store $store) {}
public static function number(mixed $number): int {if(!preg_match('/^[1-9]$/D',(string)$number)) throw new Problem('Номер набора должен быть 1–9.');return (int)$number;}
public function list(array $target, ?\stdClass $config=null): array {
$names=$this->store->read('sets.json');$checks=$this->store->read('checks.json');$result=[];$available=(new Transport($this->store,$target))->call(['op'=>'set-list'])['numbers'];
$config ??= (new Transport($this->store,$target))->readConfig()['config'];$group=$config->utm->indpic ?? new \stdClass();
foreach(range(1,9) as $number){$key=$target['id'].':'.$number;$default=(string)($group->default??'')===(string)$number;$weight=(float)($group->weights->{(string)$number}??0);$entry=$names[$key]??null;
$result[]=['number'=>$number,'name'=>$entry['name']??'','exists'=>in_array($number,$available,true),'registered'=>$entry!==null,'default'=>$default,'in_use'=>$default||$weight>0,'checked_at'=>$checks[$key]['at']??null];
}return $result;
}
public function prepareZip(string $file,int $number): string {
self::number($number);if(!is_file($file)||filesize($file)>67108864)throw new Problem('ZIP превышает лимит 64 МиБ.');
$zip=new \ZipArchive();if($zip->open($file)!==true)throw new Problem('Не удалось открыть ZIP.');$total=0;
try{if($zip->numFiles>91)throw new Problem('ZIP содержит более 90 файлов.');for($i=0;$i<$zip->numFiles;$i++){$stat=$zip->statIndex($i);$size=(int)$stat['size'];$total+=$size;if($size>8388608||$total>67108864)throw new Problem('Превышен лимит распаковки (8 МиБ на файл, 64 МиБ на набор).');$opsys=0;$attr=0;if($zip->getExternalAttributesIndex($i,$opsys,$attr)&&(($attr>>16)&0170000)===0120000)throw new Problem('Символические ссылки в ZIP запрещены.');}}finally{$zip->close();}
$dir=$this->store->dir.'/uploads/'.bin2hex(random_bytes(16));if(!mkdir($dir,0700,true))throw new Problem('Не удалось подготовить проверку ZIP.');
(new IndpicSetStorage($dir))->replaceFromZip($number,$file);return $dir;
}
public function check(array $target,int $number,bool $register=false,string $name=''): array {
$number=self::number($number);$transport=new Transport($this->store,$target);$result=$transport->call(['op'=>'set-read','number'=>$number]);
$dir=$this->store->dir.'/checks/'.bin2hex(random_bytes(16));mkdir($dir.'/'.$number,0700,true);
try {
$expected=IndpicSetStorage::expectedDimensions();if(count($result['files'])!==90)throw new Problem('Набор должен содержать ровно 90 файлов.');$hashes=[];
foreach($result['files'] as $filename=>$encoded){if(!isset($expected[$filename]))throw new Problem('В наборе найден лишний файл.');$data=base64_decode($encoded,true);if($data===false)throw new Problem('Ошибка чтения изображения.');file_put_contents($dir.'/'.$number.'/'.$filename,$data);$hashes[$filename]=hash('sha256',$data);}
(new IndpicSetStorage($dir))->validateDirectory($number);ksort($hashes);$hash=hash('sha256',json_encode($hashes));
$this->store->locked('checks',function() use($target,$number,$hash){$checks=$this->store->read('checks.json');$checks[$target['id'].':'.$number]=['at'=>gmdate(DATE_ATOM),'hash'=>$hash];$this->store->write('checks.json',$checks);});
if($register)$this->name($target,$number,$name);
return ['message'=>'Проверены 90 изображений, размеры и форматы.','hash'=>$hash];
}finally{foreach(glob($dir.'/'.$number.'/*')?:[] as $path)unlink($path);@rmdir($dir.'/'.$number);@rmdir($dir);}
}
public function manifest(array $target,int $number): ?string {return (new Transport($this->store,$target))->call(['op'=>'set-manifest','number'=>self::number($number)])['manifest'];}
public static function preparedManifest(string $dir,int $number): string {$hashes=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.self::number($number).'/'.$filename;if(!is_file($path)||is_link($path))throw new Problem('Подготовленный набор повреждён.');$hashes[$filename]=hash_file('sha256',$path);}ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));}
public function upload(array $target,int $number,string $dir,string $name,?string $expectedManifest=null,bool $hasExpected=false): array {
(new IndpicSetStorage($dir))->validateDirectory($number);$files=[];
foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.$number.'/'.$filename;$files[$filename]=['data'=>base64_encode(file_get_contents($path)),'sha256'=>hash_file('sha256',$path)];}
if(!$hasExpected)$expectedManifest=$this->manifest($target,$number);
$result=(new Transport($this->store,$target))->call(['op'=>'set-write','number'=>$number,'files'=>$files,'expected_manifest'=>$expectedManifest]);
$checked=$this->check($target,$number,true,$name);return $checked;
}
public function name(array $target,int $number,string $name): void {
self::number($number);if(preg_match('//u',$name)!==1||preg_match_all('/./us',$name)>200||preg_match('/[\x00-\x1f]/',$name))throw new Problem('Название набора слишком длинное либо содержит управляющие символы.');
$this->store->locked('sets',function() use($target,$number,$name){$sets=$this->store->read('sets.json');$sets[$target['id'].':'.$number]=['name'=>$name];$this->store->write('sets.json',$sets);});
}
public function delete(array $target,int $number): void {
$config=(new Transport($this->store,$target))->readConfig()['config'];foreach($this->list($target,$config) as $set)if($set['number']===$number&&$set['in_use'])throw new Problem('Нельзя убрать используемый набор. Сначала измените default и веса.');
$this->store->locked('sets',function() use($target,$number){$sets=$this->store->read('sets.json');unset($sets[$target['id'].':'.$number]);$this->store->write('sets.json',$sets);});
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
final class Store
{
public readonly string $dir;
public function __construct(?string $dir=null) {
$this->dir=$dir ?? (getenv('ADMIN_STATE_DIR') ?: '/var/lib/preland-admin');
foreach (['','jobs','backups','uploads','checks'] as $part) { $path=$this->dir . ($part ? '/'.$part : ''); if (!is_dir($path) && !mkdir($path,0700,true)) throw new Problem('Не удалось открыть приватное хранилище.',503); }
}
public function locked(string $key, callable $callback): mixed {
$fp=fopen($this->dir.'/lock-'.hash('sha256',$key),'c'); if (!$fp || !flock($fp,LOCK_EX)) throw new Problem('Не удалось получить блокировку.',503);
try {return $callback();} finally {flock($fp,LOCK_UN);fclose($fp);}
}
public function read(string $name, mixed $default=[]): mixed {
$path=$this->path($name); if (!is_file($path)) return $default;
try {return json_decode(file_get_contents($path),true,64,JSON_THROW_ON_ERROR);} catch(\Throwable) {throw new Problem('Повреждено приватное состояние.',503);}
}
public function write(string $name, mixed $value): void {
$path=$this->path($name);$temp=$path.'.'.bin2hex(random_bytes(8));
if (file_put_contents($temp,json_encode($value,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR),LOCK_EX)===false || !rename($temp,$path)) throw new Problem('Не удалось сохранить приватное состояние.',503);
chmod($path,0600);
}
public function path(string $name): string { if (!preg_match('~^[a-zA-Z0-9_./-]+$~D',$name) || str_contains($name,'..') || str_starts_with($name,'/')) throw new Problem('Недопустимый идентификатор.'); return $this->dir.'/'.$name; }
public function targets(bool $public=true): array {
$targets=array_values($this->read('targets.json'));
if ($public) foreach($targets as &$target) {$target['hidden']=(bool)($target['hidden']??false);$target['has_secret']=!empty($target['secret']);unset($target['secret']);}
return $targets;
}
public function target(string $id): array {foreach($this->targets(false) as $target) if ($target['id']===$id) return $target;throw new Problem('Подключение не найдено.',404);}
public function saveTarget(array $input, ?string $secret=null): array {
return $this->locked('targets',function() use($input,$secret) {
$targets=$this->read('targets.json');$id=$input['id'] ?? bin2hex(random_bytes(8));
if (!is_string($id) || !preg_match('/^[a-zA-Z0-9_-]{1,64}$/D',$id)) throw new Problem('Некорректный ID подключения.');
$old=$targets[$id] ?? [];$target=[];
foreach(['name','domain','environment','type','host','user','root','host_key'] as $key) {$value=$input[$key] ?? $old[$key] ?? '';if(!is_string($value)||strlen($value)>2048||preg_match('/[\x00-\x1f]/',$value)) throw new Problem('Некорректное поле подключения.');$target[$key]=trim($value);}
if (!preg_match('/^[a-z0-9.-]+$/iD',$target['domain']) || !$target['domain']) throw new Problem('Некорректный домен.');
if (str_ends_with(strtolower($target['domain']),'.online') && strtolower($target['domain'])!=='zoomstage.online') throw new Problem('Отключённые домены .online подключать нельзя.');
if (!in_array($target['type'],['local','sftp'],true)) throw new Problem('Допускаются только local и SFTP/SSH.');
$root=rtrim($target['root'],'/');if (!$root || !str_starts_with($root,'/') || str_contains($root,'..') || str_contains($root,'\\')) throw new Problem('Корень должен быть абсолютным безопасным путём.');$target['root']=$root;
if ($target['type']==='local') {
$roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS') ?: '')));
if (!in_array($root,$roots,true) || !is_dir($root) || realpath($root)!==$root) throw new Problem('Локальный корень не входит в явно разрешённые mount.');
} else {
if (!preg_match('/^[a-z0-9_.:-]+$/iD',$target['host']) || !preg_match('/^[a-zA-Z0-9_.-]+$/D',$target['user']) || $target['user']==='root') throw new Problem('Укажите ограниченную SSH-учётную запись и хост.');
if (!preg_match('~^(SHA256:[A-Za-z0-9+/]{43}=?|(?:ssh-|ecdsa-)[A-Za-z0-9@._+-]+ [A-Za-z0-9+/=]+)$~D',$target['host_key'])) throw new Problem('Требуется закреплённый SSH host key либо SHA256 fingerprint.');
}
$target['port']=(int)($input['port'] ?? $old['port'] ?? 22);if($target['port']<1||$target['port']>65535) throw new Problem('Некорректный порт.');
$target['id']=$id;$target['enabled']=(bool)($input['enabled'] ?? $old['enabled'] ?? false);$target['secret']=$secret ?? $old['secret'] ?? '';
$target['hidden']=(bool)($old['hidden']??false);
// Изменение транспорта сбрасывает проверку: запись включается только после test.
$identity=static fn($t)=>array_intersect_key($t,array_flip(['type','host','port','user','root','host_key','secret']));
$target['verified']=isset($old['verified']) && $identity($old)===$identity($target) ? $old['verified'] : false;
$targets[$id]=$target;$this->write('targets.json',$targets);$safe=$target;unset($safe['secret']);$safe['has_secret']=$target['secret']!=='';return $safe;
});
}
public function verifyTarget(string $id, ?array $expected=null): void {$this->locked('targets',function() use($id,$expected){$targets=$this->read('targets.json');if(!isset($targets[$id])) throw new Problem('Подключение не найдено.',404);if($expected!==null&&$targets[$id]!==$expected)throw new Problem('Подключение изменилось во время проверки. Повторите проверку.',409);$targets[$id]['verified']=true;$this->write('targets.json',$targets);});}
public function setTargetVisibility(string $id,bool $hidden): array {
return $this->locked('targets',function() use($id,$hidden){
$targets=$this->read('targets.json');if(!isset($targets[$id]))throw new Problem('Подключение не найдено.',404);
$targets[$id]['hidden']=$hidden;$this->write('targets.json',$targets);return $this->targets();
});
}
public function orderTargets(array $ids): array {
if(!array_is_list($ids))throw new Problem('Порядок должен быть списком ID.');
foreach($ids as $id)if(!is_string($id))throw new Problem('Порядок должен содержать строковые ID.');
return $this->locked('targets',function() use($ids){
$targets=$this->read('targets.json');$visible=[];
foreach($targets as $id=>$target)if(empty($target['hidden']))$visible[]=(string)$id;
if(count($ids)!==count($visible)||count(array_unique($ids,SORT_STRING))!==count($ids)||array_diff($ids,$visible))throw new Problem('Список видимых подключений изменился. Обновите страницу и повторите.',409);
$ordered=[];$position=0;
foreach($targets as $id=>$target){if(empty($target['hidden'])){$id=$ids[$position++];$target=$targets[$id];}$ordered[$id]=$target;}
$this->write('targets.json',$ordered);return $this->targets();
});
}
public function deleteTarget(string $id): void {
$this->locked('targets',function() use($id){
$targets=$this->read('targets.json');if(!isset($targets[$id]))throw new Problem('Подключение не найдено.',404);
if(empty($targets[$id]['hidden']))throw new Problem('Перед удалением скройте подключение.',409);
foreach(glob($this->dir.'/jobs/*.json')?:[] as $path){
$job=$this->read('jobs/'.basename($path));if(!in_array($job['status']??null,['queued','running'],true))continue;
$references=array_column($job['results']??[],'target');
if(isset($job['payload']['source']['id']))$references[]=$job['payload']['source']['id'];
if(in_array($id,$references,true))throw new Problem('Подключение используется незавершённым заданием. Дождитесь его завершения.',409);
}
unset($targets[$id]);$this->write('targets.json',$targets);
});
}
public function backup(string $target,string $content): string {
$id=gmdate('YmdHis').'-'.bin2hex(random_bytes(6));$items=$this->read('backups/index.json');$items[]=['id'=>$id,'target'=>$target,'created_at'=>gmdate(DATE_ATOM)];
$this->write('backups/'.$id.'.json',['content'=>$content]);$this->locked('backup-index',function() use($items){$current=$this->read('backups/index.json');$current[]=end($items);$this->write('backups/index.json',$current);});return $id;
}
public function backups(string $target): array {return array_values(array_map(static fn($item)=>array_diff_key($item,['target'=>true]),array_filter($this->read('backups/index.json'),static fn($item)=>$item['target']===$target)));}
public function backupContent(string $target,string $id): string {foreach($this->backups($target) as $item) if($item['id']===$id) return $this->read('backups/'.$id.'.json')['content'];throw new Problem('Резервная версия не найдена.',404);}
public function audit(array $data): void {$this->locked('audit',function() use($data){file_put_contents($this->dir.'/audit.jsonl',json_encode(['at'=>gmdate(DATE_ATOM)]+$data,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)."\n",FILE_APPEND|LOCK_EX);});}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
use phpseclib3\Net\SSH2;
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
final class Transport
{
private const COMMAND_LIMIT=7000;
private ?SSH2 $ssh=null;
public function __construct(private Store $store, public readonly array $target) {}
private function connect(): SSH2 {
if($this->ssh) return $this->ssh;
$t=$this->target;$ssh=new SSH2($t['host'],$t['port'],20);$key=$ssh->getServerPublicHostKey();
if(!is_string($key)) throw new Problem('Не удалось получить ключ SSH.');
$expected=$t['host_key'];$parts=explode(' ',$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');
if(!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'='))) throw new Problem('Ключ SSH хоста не совпадает с закреплённым.');
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY')){try{$secret=PublicKeyLoader::loadPrivateKey($secret);}catch(\Throwable){throw new Problem('Некорректный приватный ключ.');}}
if(!$ssh->login($t['user'],$secret)) throw new Problem('Не удалось войти в SSH.');$ssh->setTimeout(60);return $this->ssh=$ssh;
}
public function call(array $payload): array {
$payload['root']=$this->target['root'];
if($this->target['type']==='local') {
$roots=array_filter(array_map('trim',explode(',',getenv('ADMIN_LOCAL_ROOTS')?:'')));
if(!in_array($this->target['root'],$roots,true))throw new Problem('Локальный путь больше не разрешён.');
$payload['local_private']=$this->store->dir.'/local-'.hash('sha256',$this->target['root']);$result=Remote::execute($payload);
}
else {
$ssh=$this->connect();$code=file_get_contents(__DIR__.'/Remote.php');$code=preg_replace('/^<\?php\s*/','',$code);
// Сжат только доверенный helper из поставки; payload остаётся JSON-данными.
$entry="\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
$jsonPayload=json_encode($payload,JSON_THROW_ON_ERROR);
// Размер base64 проверяется ДО inlineCommand/escapeshellarg: для ZIP PHP
// завершает процесс при аргументе >2 МиБ раньше переключения на inbox.
$argumentLength=4*intdiv(strlen($jsonPayload)+2,3);
$command=$argumentLength>self::COMMAND_LIMIT?null:self::inlineCommand($code,$entry,base64_encode($jsonPayload));
$inbox=null;$sftp=null;
try {
// Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется.
if($command===null||strlen($command)>self::COMMAND_LIMIT) {
if(($payload['op']??null)==='test')throw new Problem('SSH bootstrap превышает безопасный предел команды; сократите helper или путь подключения.');
$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
$candidate=$private.'/inbox-'.bin2hex(random_bytes(16));
// Проверяем конечный bootstrap до рекурсивного preflight: большой helper
// не должен бесконечно пытаться доставить сам себя через inbox.
$command=self::inboxCommand($code,$candidate);
$this->call(['op'=>'test']);$sftp=$this->sftp();$inbox=$candidate;
if(!$sftp->put($inbox,$jsonPayload)||!$sftp->chmod(0600,$inbox))throw new Problem('Не удалось доставить подготовленный набор.');
}
$timeout=max(1,min(120,(int)(getenv('ADMIN_TRANSPORT_TIMEOUT')?:60)));$ssh->setTimeout($timeout);
$output=$ssh->exec($command);$result=is_string($output)?json_decode($output,true):null;
if($ssh->isTimeout()||!is_array($result)) { $this->ssh=null;throw new Problem('Неизвестный результат: соединение прервано. Перечитайте фактическое состояние.',504); }
} finally {
// Helper очищает inbox после чтения; резервная очистка также при обрыве
// exec/ошибке загрузки, не подменяя исходную ошибку транспорта.
if($inbox!==null&&$sftp!==null)try{$sftp->delete($inbox);}catch(\Throwable){}
}
}
if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result;
}
private static function inlineCommand(string $code,string $entry,string $argument): string {
if(!function_exists('gzdeflate'))throw new Problem('Для SSH-транспорта требуется расширение zlib.');
$compressed=gzdeflate($code.$entry,9);if($compressed===false)throw new Problem('Не удалось подготовить SSH helper.');
$runner='if(!function_exists("gzinflate")){echo json_encode(["ok"=>false,"error"=>"На удалённом PHP отсутствует zlib (gzinflate)."]);exit(1);}eval(gzinflate(base64_decode('.var_export(base64_encode($compressed),true).',true)));';
return 'php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($argument);
}
private static function inboxCommand(string $code,string $inbox): string {
$entry="\n\$inbox=\$argv[1];try{echo json_encode(\\PrelandAdmin\\Remote::execute(json_decode(file_get_contents(\$inbox),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);}finally{unlink(\$inbox);}";
$command=self::inlineCommand($code,$entry,$inbox);
if(strlen($command)>self::COMMAND_LIMIT)throw new Problem('SSH helper превышает безопасный предел команды; требуется уменьшить bootstrap.');
return $command;
}
private function sftp(): SFTP {
$t=$this->target;$sftp=new SFTP($t['host'],$t['port'],20);$key=$sftp->getServerPublicHostKey();$parts=explode(' ',(string)$key);$fp='SHA256:'.rtrim(base64_encode(hash('sha256',base64_decode($parts[1]??'',true)?:'',true)),'=');$expected=$t['host_key'];
if(!is_string($key)||!hash_equals(rtrim($expected,'='),rtrim(str_starts_with($expected,'SHA256:')?$fp:$key,'=')))throw new Problem('Ключ SFTP хоста не совпадает.');
$secret=$t['secret'];if(str_contains($secret,'PRIVATE KEY'))$secret=PublicKeyLoader::loadPrivateKey($secret);if(!$sftp->login($t['user'],$secret))throw new Problem('Не удалось войти в SFTP.');return $sftp;
}
public function test(): array {
$result=$this->call(['op'=>'test']);
$read=$this->readConfig();
if (!hash_equals($result['version'],$read['version'])) throw new Conflict();
PercentWeights::validate($read['config']);
return $result;
}
public function readConfig(): array {$r=$this->call(['op'=>'read']);return ['config'=>Config::decode($r['content']),'version'=>$r['version'],'content'=>$r['content']];}
public function writeConfig(string $version,string $content): array {
$config=Config::decode($content);Config::assertWritable($config);PercentWeights::validate($config);
$before=$this->call(['op'=>'read']);if(!hash_equals($version,$before['version']))throw new Conflict();
$this->store->backup($this->target['id'],$before['content']);
return $this->call(['op'=>'write','version'=>$version,'content'=>$content]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace PrelandAdmin;
/** Независимые снимки UTM: без доменов, подключений, заметок и происхождения. */
final class UtmPresets
{
private const GROUPS=['indpic','theme','degr','reg','banch','tp','cons','focus','part'];
public function __construct(private Store $store) {}
public function all(): array {
return array_values(array_map(static fn($p)=>['id'=>$p['id'],'name'=>$p['name']],$this->store->read('utm-presets.json')));
}
public function get(string $id): array {
if(!preg_match('/^[a-f0-9]{24}$/D',$id))throw new Problem('Некорректный ID набора.');
$presets=$this->store->read('utm-presets.json');if(!isset($presets[$id]))throw new Problem('Набор удалён или не найден. Обновите список.',404);
$p=$presets[$id];$p['utm']=Config::decode(json_encode($p['utm'],JSON_THROW_ON_ERROR));return $p;
}
public function create(array $data): array {
if(count($data)!==2||array_diff(array_keys($data),['name','utm'])||!is_string($data['name']??null)||!(($data['utm']??null) instanceof \stdClass))throw new Problem('Передайте только название и объект UTM.');
$name=trim($data['name']);if($name===''||!preg_match('/^[^\x00-\x1f\x7f]{1,120}$/uD',$name))throw new Problem('Укажите название от 1 до 120 символов без управляющих знаков.');
$utm=$data['utm'];if(!get_object_vars($utm)||array_diff(array_keys(get_object_vars($utm)),self::GROUPS))throw new Problem('Набор должен содержать только редактируемые группы UTM.');
foreach(get_object_vars($utm) as $group){if(!$group instanceof \stdClass||count(get_object_vars($group))!==2||array_diff(array_keys(get_object_vars($group)),['default','weights']))throw new Problem('В группе разрешены только default и weights, без заметок.');}
// Канонизация использует тот же контракт, что применение на домен.
$changes=Copy::project((object)['utm'=>$utm]);$config=Config::patch((object)[], $changes);Config::validate($config);
return $this->store->locked('utm-presets',function()use($name,$config){
$presets=$this->store->read('utm-presets.json');foreach($presets as $p)if(preg_match('/^'.preg_quote($name,'/').'$/iuD',$p['name']))throw new Problem('Набор с таким названием уже существует. Укажите другое имя.',409);
$id=bin2hex(random_bytes(12));$record=['id'=>$id,'name'=>$name,'utm'=>$config->utm];$presets[$id]=$record;$this->store->write('utm-presets.json',$presets);return $record;
});
}
public function delete(string $id): void {
$this->store->locked('utm-presets',function()use($id){$this->get($id);$presets=$this->store->read('utm-presets.json');unset($presets[$id]);$this->store->write('utm-presets.json',$presets);});
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
$port=random_int(19000,29000);$env=getenv();$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test-admin';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture-admin-password',PASSWORD_DEFAULT);$env['ADMIN_STATE_DIR']=$root.'/state';$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
$env['PHP_CLI_SERVER_WORKERS']='4';
$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);if(!is_resource($server))throw new RuntimeException('server failed');
$cookie='';
function request(string $path,string $method='GET',?array $body=null,?string $csrf=null): array {
global $port,$cookie;
$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;
$context=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>30]]);
$result=file_get_contents('http://127.0.0.1:'.$port.$path,false,$context);$responseHeaders=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$responseHeaders[0]??'',$match);
foreach($responseHeaders as $header)if(str_starts_with(strtolower($header),'set-cookie: '))$cookie=explode(';',substr($header,12))[0];
return [(int)($match[1]??0),json_decode((string)$result,true,64,JSON_THROW_ON_ERROR),$responseHeaders];
}
try{
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(50000);}while(microtime(true)<$deadline);
[$status,$body,$anonymousHeaders]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked');
check(!array_filter($anonymousHeaders,static fn($header)=>str_starts_with(strtolower($header),'set-cookie: preland_admin=')),'HTTP anonymous state cannot overwrite a later authenticated cookie');
[$status,$body]=request('/api/link-builder?target=test');check($status===401,'HTTP link builder requires authentication');
foreach(['/api/targets/visibility','/api/targets/order'] as $route){[$status]=request($route,'POST',['id'=>'test']);check($status===401,'HTTP target organization requires authentication');}
[$status,$body,$headers]=request('/api/login','POST',['username'=>'test-admin','password'=>'fixture-admin-password']);check($status===200&&isset($body['csrf']),'HTTP login creates authenticated CSRF session');$csrf=$body['csrf'];
check((bool)array_filter($headers,static fn($h)=>str_contains(strtolower($h),'httponly')&&str_contains(strtolower($h),'samesite=strict')),'session cookie HttpOnly and SameSite Strict');
[$status,$body]=request('/api/state');check($status===200&&isset($body['targets'][0])&&!array_key_exists('secret',$body['targets'][0]),'HTTP state redacts transport credentials');
[$status]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>true]);check($status===403,'HTTP visibility requires CSRF');
[$status]=request('/api/targets/order','POST',['ids'=>['test']]);check($status===403,'HTTP order requires CSRF');
[$status,$body]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>true],$csrf);check($status===200&&$body['targets'][0]['hidden']===true&&!isset($body['targets'][0]['secret']),'HTTP visibility returns redacted updated targets');
foreach([['id'=>'test','hidden'=>'true'],['id'=>'test','hidden'=>1],['id'=>['test'],'hidden'=>true],['id'=>'test'],['id'=>'test','hidden'=>false,'enabled'=>true]] as $invalid){[$status]=request('/api/targets/visibility','POST',$invalid,$csrf);check($status===422,'HTTP visibility rejects wrong shapes and extra fields');}
[$status]=request('/api/targets/visibility','POST',['id'=>'absent','hidden'=>true],$csrf);check($status===404,'HTTP visibility missing target');
foreach([['ids'=>(object)['0'=>'test']],['ids'=>'test'],['ids'=>[1]],['ids'=>[],'extra'=>true]] as $invalid){[$status]=request('/api/targets/order','POST',$invalid,$csrf);check($status===422,'HTTP order requires only a JSON string array');}
[$status,$body]=request('/api/targets/order','POST',['ids'=>[]],$csrf);check($status===200&&$body['targets'][0]['id']==='test','HTTP all-hidden empty order accepted');
[$status]=request('/api/targets/order','POST',['ids'=>['test']],$csrf);check($status===409,'HTTP stale visible order conflicts after hide');
[$status]=request('/api/targets/visibility','POST',['id'=>'test','hidden'=>false],$csrf);check($status===200,'HTTP restore visible target');
foreach([[],['test','test'],['absent']] as $invalid){[$status]=request('/api/targets/order','POST',['ids'=>$invalid],$csrf);check($status===409,'HTTP incomplete duplicate unknown order conflicts');}
[$status]=request('/api/targets','DELETE',['id'=>'test'],$csrf);check($status===409,'HTTP visible deletion rejected');
[$status]=request('/api/targets','DELETE',['id'=>'absent'],$csrf);check($status===404,'HTTP missing deletion rejected');
[$status]=request('/api/targets','POST',['target'=>['id'=>'delete-fixture','domain'=>'delete.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]],$csrf);check($status===200,'HTTP creates disposable deletion fixture');
[$status]=request('/api/targets/visibility','POST',['id'=>'delete-fixture','hidden'=>true],$csrf);check($status===200,'HTTP hides disposable deletion fixture');
[$status,$body]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===202,'HTTP hidden target remains available to explicit jobs');$deleteJob=$body['job']['id'];
[$status]=request('/api/targets','DELETE',['id'=>'delete-fixture'],$csrf);check($status===409,'HTTP queued job prevents hidden deletion');
$pending=$store->read('jobs/'.$deleteJob.'.json');$pending['status']='completed';$store->write('jobs/'.$deleteJob.'.json',$pending);
[$status]=request('/api/targets','DELETE',['id'=>'delete-fixture'],$csrf);check($status===200&&is_file($root.'/site/config.json'),'HTTP idle hidden deletion keeps site config');
$countBefore=count(glob($store->dir.'/jobs/*.json'));
[$status]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===404&&count(glob($store->dir.'/jobs/*.json'))===$countBefore,'HTTP stale enqueue cannot create job after connection deletion');
[$status,$body]=request('/api/apply','POST',['targets'=>[],'changes'=>[]]);check($status===403,'HTTP mutation rejects missing CSRF');
[$status,$body]=request('/api/config?target=test');check($status===200&&isset($body['version'],$body['config']),'HTTP config reads actual target version');$version=$body['version'];
file_put_contents($store->dir.'/catalog.json','invalid obsolete catalog');
[$status,$withoutCatalog]=request('/api/config?target=test');check($status===200&&!array_key_exists('catalog',$withoutCatalog)&&$withoutCatalog['warnings']===[],'HTTP config ignores even corrupt obsolete catalog without warnings');
check(is_string($body['read_at']??null)&&preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$body['read_at'])===1&&abs(time()-strtotime($body['read_at']))<10,'HTTP config includes actual successful read timestamp as response metadata');
check(!array_key_exists('read_at',$store->target('test'))&&!property_exists(json_decode(file_get_contents($root.'/site/config.json')),'read_at'),'read timestamp does not enter target or runtime config');
[$status,$body]=request('/api/apply','POST',['targets'=>[['id'=>'test','version'=>$version]],'changes'=>[['path'=>['host'],'value'=>'http.example']]],$csrf);check($status===202&&isset($body['job']['id']),'HTTP apply queues persistent job');$id=$body['job']['id'];
(new PrelandAdmin\Jobs($store))->run($id);
[$status,$body]=request('/api/jobs?id='.$id);check($body['job']['results'][0]['status']==='success'&&$body['job']['changes'][0]['path']===['host'],'HTTP persisted result and changed paths visible');
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body['selection']===null&&$body['url']===null&&$body['saved_at']===null,'HTTP new link builder state is explicitly empty');
$linkSelection=array_fill_keys(['indpic','theme','degr','reg','banch','tp','cons','focus','part'],'rnd');$runtimeHash=hash_file('sha256',$root.'/site/config.json');$jobCount=count(glob($store->dir.'/jobs/*.json'));
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection]);check($status===403,'HTTP link generation requires CSRF');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&$body['selection']===$linkSelection&&str_starts_with($body['url'],'https://test.invalid/?'),'HTTP explicit link generation returns saved independent choice');$savedLink=$body;
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&$body===$savedLink,'HTTP reload returns exact stored URL without fresh noise');
$linkSelection['focus']='2';[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection],$csrf);check($status===200&&array_diff_assoc($body['selection'],$savedLink['selection'])===['focus'=>'2'],'HTTP changing one link selector preserves other choices');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection+['evil'=>'1']],$csrf);check($status===422,'HTTP unexpected selection key rejected');
[$status,$body]=request('/api/link-builder','POST',['target'=>['test'],'selection'=>$linkSelection],$csrf);check($status===422,'HTTP non-string target rejected with safe validation status');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>[]],$csrf);check($status===422,'HTTP selection must be complete object');
[$status,$body]=request('/api/link-builder','POST',['target'=>'test','selection'=>$linkSelection,'url'=>'javascript:alert(1)'],$csrf);check($status===422,'HTTP caller cannot inject URL or extra top-level fields');
[$status,$body]=request('/api/targets','POST',['target'=>['id'=>'test','domain'=>'renamed.invalid']],$csrf);check($status===200,'HTTP target domain rename accepted');
[$status,$body]=request('/api/link-builder?target=test');check($status===200&&str_starts_with($body['url'],'https://renamed.invalid/?')&&$body['selection']===$linkSelection,'HTTP saved selection survives domain rename with current URL base');
// Настоящий медленный SSH read: отдельный TCP fixture принимает соединение без SSH banner.
$slowPort=random_int(30000,39000);$accepted=$root.'/slow-accepted';$ready=$root.'/slow-ready';
$tcpCode='$server=stream_socket_server("tcp://127.0.0.1:".$argv[1]);if(!$server)exit(2);file_put_contents($argv[2],"ready");$client=stream_socket_accept($server,10);if($client){file_put_contents($argv[3],"accepted");sleep(3);fclose($client);}fclose($server);';
$tcp=proc_open([PHP_BINARY,'-r',$tcpCode,(string)$slowPort,$ready,$accepted],[0=>['pipe','r'],1=>$log,2=>$log],$tcpPipes);fclose($tcpPipes[0]);
$slowRead=null;
try{
$until=microtime(true)+5;while(!is_file($ready)&&microtime(true)<$until)usleep(10000);if(!is_file($ready))throw new RuntimeException('Slow TCP fixture unavailable');
$targets=$store->read('targets.json');$targets['slow']=['id'=>'slow','domain'=>'slow.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>$slowPort,'user'=>'fixture','host_key'=>'SHA256:'.str_repeat('A',43),'secret'=>'fixture','root'=>'/not-mounted','enabled'=>true];$store->write('targets.json',$targets);
$readCode='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>10]]);@file_get_contents($argv[1],false,$ctx);';
$slowRead=proc_open([PHP_BINARY,'-r',$readCode,'http://127.0.0.1:'.$port.'/api/config?target=slow',$cookie],[0=>['pipe','r'],1=>$log,2=>$log],$readPipes);fclose($readPipes[0]);
$until=microtime(true)+5;while(!is_file($accepted)&&microtime(true)<$until)usleep(10000);if(!is_file($accepted))throw new RuntimeException('Slow authenticated config read did not reach transport');
$started=microtime(true);[$status,$body]=request('/api/link-builder?target=test');$elapsed=microtime(true)-$started;
check($status===200&&$elapsed<1.5,'slow remote config read does not block link builder in same authenticated session');
}finally{if(is_resource($slowRead))proc_close($slowRead);proc_close($tcp);}
check(hash_file('sha256',$root.'/site/config.json')===$runtimeHash&&count(glob($store->dir.'/jobs/*.json'))===$jobCount,'HTTP link generation does not change runtime or enqueue jobs');
[$status,$body]=request('/api/logout','POST',null,$csrf);check($status===200,'HTTP logout succeeds');
[$status,$body]=request('/api/state');check($status===401,'HTTP logout invalidates session');
echo "HTTP integration complete\n";
}finally{proc_terminate($server);proc_close($server);fclose($log);}
+11
View File
@@ -0,0 +1,11 @@
#define _GNU_SOURCE
#include <dlfcn.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
int rename(const char *oldpath, const char *newpath) {
int (*original)(const char *, const char *) = dlsym(RTLD_NEXT, "rename");
int result = original(oldpath, newpath);
if (result == 0 && strstr(newpath, "/.preland-admin-staging/backup-1-") != NULL) kill(getpid(), SIGKILL);
return result;
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
if(($argv[1]??'')==='--crash-child') {require dirname(__DIR__).'/bootstrap.php';$p=json_decode(file_get_contents($argv[2]),true,64,JSON_THROW_ON_ERROR);PrelandAdmin\Remote::execute($p);exit;}
require __DIR__.'/backend-security.php';
use PrelandAdmin\{Config,Sets,Conflict};
require dirname(__DIR__,2).'/components/utm-codec.php';
$resolverConfig=['utm'=>['indpic'=>['default'=>'2','weights'=>['1'=>0,'2'=>0]]]];
check(prelandResolveUtm(['indpic'=>'abcd2'],$resolverConfig)['digits']['indpic']==='2','configured zero-weight indpic remains selectable explicitly by actual resolver');
check(prelandResolveUtm(['indpic'=>'abcd0'],$resolverConfig)['digits']['indpic']==='1','actual generic resolver zero-sum selects first configured variant');
$legacy=Config::decode('{"host":"zoomzilla.ru","utm":{"registration":{"default":"before","weights":{"before":100,"after":0},"extra":true}}}');
$changed=Config::patch($legacy,[['path'=>['utm','reg','default'],'value'=>'2']]);check($changed->utm->registration->default==='after'&&!isset($changed->utm->reg)&&$changed->utm->registration->extra===true,'canonical default adapts to legacy target without duplicate alias');
$changed=Config::patch($legacy,[['path'=>['utm','reg','weights'],'value'=>(object)['1'=>0,'2'=>2]]]);check($changed->utm->registration->weights->after===2,'numeric weight group adapts to legacy representation');
$modern=Config::decode('{"host":"zoomzilla.ru","utm":{"reg":{"default":"1","weights":{"1":100,"2":0}}}}');$changed=Config::patch($modern,[['path'=>['utm','registration','default'],'value'=>'after']]);check($changed->utm->reg->default==='2'&&!isset($changed->utm->registration),'legacy default adapts to modern target');
$changed=Config::patch($modern,[['path'=>['utm','registration','weights','before'],'value'=>5]]);check($changed->utm->reg->weights->{'1'}===5,'legacy weight path adapts to numeric target');
rejects(fn()=>Config::patch(Config::decode('{"utm":{"reg":{},"registration":{}}}'),[['path'=>['utm','reg','default'],'value'=>'2']]),'pre-existing two aliases rejected before adapter');
check(Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":100,"9":0}}}}'))===[],'zero-weight indpic can reference manually entered variant');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'))===[],'enum default does not invent weights-membership restriction');
$store->write('catalog.json',['host'=>'zoomzilla.ru','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[],'tp'=>[],'cons'=>[]]]);
$manual=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":100}}}}');check(Config::validate($manual)===[],'manual groups require no catalog lookup or availability warnings');
$validPrepared=$sets->prepareZip($zipPath,1);$source=$sets->manifest($target,1);$desired=Sets::preparedManifest($validPrepared,1);
$original=file_get_contents($root.'/site/1/01_desk_1-source.jpg');$img=new Imagick();$img->newImage(1696,920,'#aabbcc');$img->setImageFormat('jpg');file_put_contents($root.'/site/1/01_desk_1-source.jpg',$img->getImagesBlob());$img->clear();$foreign=$sets->manifest($target,1);
rejects(fn()=>$sets->upload($target,1,$validPrepared,'CAS',$source,true),'set CAS rejects third-party manifest changed after read');check($sets->manifest($target,1)===$foreign,'set CAS preserves third-party images');
$unknown=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Retry']);$r=$store->read('jobs/'.$unknown['id'].'.json');$r['status']='completed';$r['results'][0]=['target'=>'test','status'=>'unknown','message'=>'fixture','source_manifest'=>$source,'desired_manifest'=>$desired];$store->write('jobs/'.$unknown['id'].'.json',$r);$jobs->retry($unknown['id'],['test']);$jobs->run($unknown['id']);check($jobs->get($unknown['id'])['results'][0]['status']==='conflict'&&$sets->manifest($target,1)===$foreign,'unknown upload retry detects third-party publication and never overwrites it');
$sets->upload($target,1,$validPrepared,'CAS',$foreign,true);$unknown2=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Retry']);$r=$store->read('jobs/'.$unknown2['id'].'.json');$r['status']='completed';$r['results'][0]=['target'=>'test','status'=>'unknown','message'=>'fixture','source_manifest'=>$foreign,'desired_manifest'=>$desired];$store->write('jobs/'.$unknown2['id'].'.json',$r);$before=count(glob($store->dir.'/local-*/backup-1-*')?:[]);$jobs->retry($unknown2['id'],['test']);$jobs->run($unknown2['id']);check($jobs->get($unknown2['id'])['results'][0]['status']==='success'&&count(glob($store->dir.'/local-*/backup-1-*')?:[])===$before,'already-published desired upload reconciles without another swap or backup');
// SIGKILL в реальном libc rename, после переноса old→backup, без production test hooks.
$library=$root.'/kill-rename.so';$compile=proc_open(['gcc','-shared','-fPIC','-o',$library,__DIR__.'/backend-kill-rename.c','-ldl'],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes);fclose($pipes[0]);check(proc_close($compile)===0,'fault injector builds inside Docker');
$files=[];foreach(array_keys(PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $name)$files[$name]=['data'=>base64_encode(file_get_contents($validPrepared.'/1/'.$name)),'sha256'=>hash_file('sha256',$validPrepared.'/1/'.$name)];$private=$store->dir.'/local-'.hash('sha256',$target['root']);$request=['op'=>'set-write','root'=>$target['root'],'local_private'=>$private,'number'=>1,'files'=>$files,'expected_manifest'=>$sets->manifest($target,1)];$input=$root.'/swap.json';file_put_contents($input,json_encode($request,JSON_THROW_ON_ERROR));$env=getenv();$env['LD_PRELOAD']=$library;$child=proc_open([PHP_BINARY,__FILE__,'--crash-child',$input],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes,null,$env);fclose($pipes[0]);$exit=proc_close($child);check($exit!==0&&!is_dir($target['root'].'/1')&&is_file($private.'/swap-1.json'),'SIGKILL after first rename leaves durable recoverable journal');
$crashed=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Crash recovery']);$record=$store->read('jobs/'.$crashed['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$record['results'][0]['desired_manifest']=$desired;$store->write('jobs/'.$crashed['id'].'.json',$record);
$jobs->interrupted($crashed['id']);clearstatcache();check(is_dir($target['root'].'/1')&&!is_file($private.'/swap-1.json'),'supervisor interruption automatically restores active folder after SIGKILL without user read');
check($jobs->get($crashed['id'])['results'][0]['status']==='unknown'&&$sets->manifest($target,1)===$desired,'automatic journal recovery preserves complete set and unknown operation status');
$unavailable=$jobs->create('upload',['targets'=>['missing'],'number'=>1,'prepared'=>$validPrepared]);$record=$store->read('jobs/'.$unavailable['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$store->write('jobs/'.$unavailable['id'].'.json',$record);$jobs->interrupted($unavailable['id']);$result=$jobs->get($unavailable['id'])['results'][0];check($result['status']==='unknown'&&($result['recovery_checked']??null)===false,'failed automatic recovery remains explicitly unconfirmed and unknown');
echo "Review regression complete\n";
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
use PrelandAdmin\{Config,Jobs,Sets,IndpicSetStorage,Auth,Problem};
function rejects(callable $call,string $message): void {try{$call();}catch(\Throwable $e){check(true,$message);return;}throw new RuntimeException('Accepted invalid input: '.$message);}
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":100}},"registration":{"default":"before","weights":{"before":100}}}}')),'old/new UTM alias conflict rejected');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'))===[],'manual numeric theme variant accepted');
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}')),'negative UTM weight rejected');
check(Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'))===[],'config editor does not require a physical set');
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":0,"light":0}}}}'))===[],'legacy semantic enum remains valid without rewriting');
$current=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$current['version']]],'changes'=>[['path'=>['custom','empty'],'value'=>(object)[]]]]);$jobs->run($job['id']);
check($transport->readConfig()['config']->custom->empty instanceof stdClass,'empty JSON object survives persisted job');
$state=$transport->readConfig();$id=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$state['version']]],'changes'=>[['path'=>['host'],'value'=>'resumed.example']]])['id'];
$record=$store->read('jobs/'.$id.'.json');$record['status']='running';$record['results'][0]['status']='running';$desired=Config::encode(Config::patch($state['config'],[['path'=>['host'],'value'=>'resumed.example']]));$record['results'][0]['desired_hash']=hash('sha256',$desired);$store->write('jobs/'.$id.'.json',$record);$transport->writeConfig($state['version'],$desired);(new Jobs($store))->run($id);
check($jobs->get($id)['results'][0]['status']==='success','restart after write reconciles desired hash without overwrite');
$race=$transport->readConfig();$processes=[];foreach(['race-a.example','race-b.example'] as $host){$pid=pcntl_fork();if($pid===0){try{$transport->writeConfig($race['version'],Config::encode(Config::patch($race['config'],[['path'=>['host'],'value'=>$host]])));exit(0);}catch(PrelandAdmin\Conflict){exit(7);}}$processes[]=$pid;}$codes=[];foreach($processes as $pid){pcntl_waitpid($pid,$status);$codes[]=pcntl_wexitstatus($status);}sort($codes);check($codes===[0,7],'two simultaneous writes produce one success and one conflict');
$sets=new Sets($store);$zipPath=$root.'/bad.zip';$zip=new ZipArchive();$zip->open($zipPath,ZipArchive::CREATE);$zip->addFromString('1/../../evil','bad');$zip->close();rejects(fn()=>$sets->prepareZip($zipPath,1),'ZIP traversal rejected');
$zipPath=$root.'/valid.zip';$zip=new ZipArchive();$zip->open($zipPath,ZipArchive::CREATE);$cache=[];
foreach(IndpicSetStorage::expectedDimensions() as $name=>$dimension){$ext=pathinfo($name,PATHINFO_EXTENSION);$key=implode('x',$dimension).'.'.$ext;if(!isset($cache[$key])){$image=new Imagick();$image->newImage($dimension['width'],$dimension['height'],'#345678');$image->setImageFormat($ext);$cache[$key]=$image->getImagesBlob();$image->clear();}$zip->addFromString('1/'.$name,$cache[$key]);}$zip->close();
$prepared=$sets->prepareZip($zipPath,1);check(is_dir($prepared.'/1'),'90 images with CPanel sizes accepted');
$missing=$root.'/missing.zip';copy($zipPath,$missing);$z=new ZipArchive();$z->open($missing);$z->deleteName('1/01_desk_1-source.jpg');$z->close();rejects(fn()=>$sets->prepareZip($missing,1),'ZIP missing required image rejected');
$extra=$root.'/extra.zip';copy($zipPath,$extra);$z=new ZipArchive();$z->open($extra);$z->addFromString('1/extra.jpg','bad');$z->close();rejects(fn()=>$sets->prepareZip($extra,1),'ZIP extra image rejected');
$wrong=$root.'/wrong-size.zip';copy($zipPath,$wrong);$z=new ZipArchive();$z->open($wrong);$image=new Imagick();$image->newImage(10,10,'red');$image->setImageFormat('jpg');$z->addFromString('1/01_desk_1-source.jpg',$image->getImagesBlob());$image->clear();$z->close();rejects(fn()=>$sets->prepareZip($wrong,1),'ZIP incorrect dimensions rejected');
$oversize=$root.'/oversize.zip';$sparse=fopen($oversize,'w');ftruncate($sparse,67108865);fclose($sparse);rejects(fn()=>$sets->prepareZip($oversize,1),'ZIP compressed upload limit enforced before extraction');
function rawZip(array $entries): string {$local='';$central='';foreach($entries as [$name,$data,$declared]){$offset=strlen($local);$size=strlen($data);$crc=crc32($data);$local.=pack('VvvvvvVVVvv',0x04034b50,20,0,0,0,0,$crc,$size,$declared,strlen($name),0).$name.$data;$central.=pack('VvvvvvvVVVvvvvvVV',0x02014b50,20,20,0,0,0,0,$crc,$size,$declared,strlen($name),0,0,0,0,0,$offset).$name;}return $local.$central.pack('VvvvvVVv',0x06054b50,0,0,count($entries),count($entries),strlen($central),strlen($local),0);}
$duplicate=$root.'/duplicate.zip';file_put_contents($duplicate,rawZip([['1/01_desk_1-source.jpg','a',1],['1/01_desk_1-source.jpg','b',1]]));rejects(fn()=>$sets->prepareZip($duplicate,1),'duplicate ZIP entry rejected');
$inflated=$root.'/inflated.zip';file_put_contents($inflated,rawZip([['1/01_desk_1-source.jpg','a',8388609]]));rejects(fn()=>$sets->prepareZip($inflated,1),'declared ZIP expansion limit rejected before image decoding');
$result=$sets->upload($target,1,$prepared,'Тестовый набор');check(str_contains($result['message'],'90'),'local set publication revalidates delivered files');
$sets->check($target,1);check(is_file($root.'/site/1/01_desk_1-source.jpg'),'set directory published');
check((fileperms($root.'/site/1')&0777)===0755,'published set directory is readable by independent web-server UID');
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":100}}}}')),'default without weight variant rejected instead of runtime fallback');
$kept=hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg');$badFiles=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename)$badFiles[$filename]=['data'=>base64_encode(file_get_contents($prepared.'/1/'.$filename)),'sha256'=>str_repeat('0',64)];
rejects(fn()=>$transport->call(['op'=>'set-write','number'=>1,'files'=>$badFiles,'expected_manifest'=>$sets->manifest($target,1)]),'delivery hash mismatch rejects activation');check(hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg')===$kept,'failed set publication preserves previous working image');
// Настоящий формат должен совпадать с расширением, даже если размеры верны.
$jpg=$prepared.'/1/01_desk_1-source.jpg';$image=new Imagick();$image->newImage(1696,920,'red');$image->setImageFormat('png');file_put_contents($jpg,$image->getImagesBlob());$image->clear();
rejects(fn()=>(new IndpicSetStorage($prepared))->validateDirectory(1),'extension and decoded image format mismatch rejected');
echo "Security contract complete\n";
+7
View File
@@ -0,0 +1,7 @@
FROM zoomzilla-api:issue328-591a67c
USER root
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /bin/bash fixture && echo 'fixture:fixture-only-password' | chpasswd \
&& mkdir -p /run/sshd /home/fixture/site && chown -R fixture:fixture /home/fixture/site
ENTRYPOINT ["/bin/sh", "-c"]
CMD ["ssh-keygen -A >/dev/null 2>&1; /usr/sbin/sshd -p 2222; php /work/admin/tests/backend-sftp.php"]
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend-security.php';
use PrelandAdmin\{Store,Transport,Config,Sets};
$site='/home/fixture/site';file_put_contents($site.'/config.json',Config::encode((object)['host'=>'example.org','utm'=>(object)[]]));chown($site.'/config.json','fixture');
$ssh=new phpseclib3\Net\SSH2('127.0.0.1',2222);$key=$ssh->getServerPublicHostKey();check(is_string($key),'real SSH host key available');
$sftpTarget=$store->saveTarget(['id'=>'sftp','name'=>'SFTP fixture','domain'=>'fixture.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>2222,'user'=>'fixture','host_key'=>$key,'root'=>$site,'enabled'=>true],'fixture-only-password');
$store->verifyTarget('sftp');$sftpTarget=$store->target('sftp');$remote=new Transport($store,$sftpTarget);
$remote->test();check(true,'real SSH remote lock and permission preflight');$remoteRead=$remote->readConfig();check($remoteRead['config']->host==='example.org','real SSH config read');
$remote->writeConfig($remoteRead['version'],Config::encode((object)['host'=>'ssh-written.example','utm'=>(object)[]]));check($remote->readConfig()['config']->host==='ssh-written.example','real SSH atomic CAS and reread');
rejects(fn()=>$remote->writeConfig($remoteRead['version'],Config::encode((object)['host'=>'stale.example'])),'real remote stale version rejected');
$bad=$sftpTarget;$bad['host_key']='SHA256:'.str_repeat('A',43);rejects(fn()=>(new Transport($store,$bad))->test(),'pinned SSH key mismatch rejected before login');
// Восстановить проверенный набор после намеренной порчи формата security-тестом.
$preparedRemote=$sets->prepareZip($zipPath,1);$sets->upload($sftpTarget,1,$preparedRemote,'SFTP проверка');check(is_file($site.'/1/01_desk_1-source.jpg'),'real SFTP image transfer, remote activation, checksum and decode');
// Реальный обрыв ожидания после удалённой записи: fixture задерживает завершение команды.
$wrapper=<<<'SH'
#!/bin/sh
/usr/bin/php "$@"
if [ -f /home/fixture/delay-next-write ]; then
op=$(/usr/bin/php -r '$p=json_decode(base64_decode($argv[1]),true);echo $p["op"]??"";' "$5")
if [ "$op" = write ]; then rm /home/fixture/delay-next-write; sleep 3; fi
fi
SH;
file_put_contents('/usr/local/bin/php',$wrapper);chmod('/usr/local/bin/php',0755);
$beforeTimeout=$remote->readConfig();$timeoutJob=$jobs->create('apply',['targets'=>[['id'=>'sftp','version'=>$beforeTimeout['version']]],'changes'=>[['path'=>['host'],'value'=>'timeout-written.example']]]);
file_put_contents('/home/fixture/delay-next-write','1');chown('/home/fixture/delay-next-write','fixture');putenv('ADMIN_TRANSPORT_TIMEOUT=1');$jobs->run($timeoutJob['id']);
check($jobs->get($timeoutJob['id'])['results'][0]['status']==='unknown','real SSH timeout after write persists unknown result');
putenv('ADMIN_TRANSPORT_TIMEOUT=60');$jobs->retry($timeoutJob['id'],['sftp']);$jobs->run($timeoutJob['id']);$retried=$jobs->get($timeoutJob['id']);check($retried['results'][0]['status']==='success','retry reads committed hash after real transport timeout');
echo "SFTP integration complete\n";
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend-security.php';
// Рабочий набор уже опубликован security fixture, сохраняем отдельный свежий subprocess на job.
$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
$queued1=$jobs->create('check',['targets'=>['test'],'number'=>1]);$queued2=$jobs->create('check',['targets'=>['test'],'number'=>1]);
$onceLog=fopen($root.'/once.log','a');$once=proc_open([PHP_BINARY,dirname(__DIR__).'/bin/worker.php','--once'],[0=>['pipe','r'],1=>$onceLog,2=>$onceLog],$oncePipes,null,$env);fclose($oncePipes[0]);check(proc_close($once)===0,'worker once exits successfully');fclose($onceLog);
$statuses=[$jobs->get($queued1['id'])['status'],$jobs->get($queued2['id'])['status']];sort($statuses);check($statuses===['completed','queued'],'worker once processes exactly one queued job');
$record=$store->read('jobs/'.$queued1['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$record['results'][0]['desired_hash']='preserved-reconciliation-marker';$store->write('jobs/'.$queued1['id'].'.json',$record);$jobs->interrupted($queued1['id']);$interrupted=$store->read('jobs/'.$queued1['id'].'.json');check($interrupted['results'][0]['status']==='unknown'&&$interrupted['results'][0]['desired_hash']==='preserved-reconciliation-marker','crashed child becomes unknown and retains reconciliation state');
$log=fopen($root.'/worker.log','a');$started=microtime(true);$process=proc_open([PHP_BINARY,dirname(__DIR__).'/bin/worker.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);
function awaitJob(string $id,int $seconds=25): array {global $jobs;$until=microtime(true)+$seconds;do{$job=$jobs->get($id);if($job['status']==='completed')return $job;usleep(100000);}while(microtime(true)<$until);throw new RuntimeException('Worker job timeout');}
try{
$first=$jobs->create('check',['targets'=>['test'],'number'=>1]);$one=awaitJob($first['id']);check($one['results'][0]['status']==='success','worker subprocess validates first 90-image job');
while(microtime(true)-$started<65)sleep(1);
$second=$jobs->create('check',['targets'=>['test'],'number'=>1]);$two=awaitJob($second['id']);check($two['results'][0]['status']==='success','worker still validates 90 images after process wall time exceeds 60 seconds');
$status=proc_get_status($process);check($status['running'],'supervisor remains alive beyond Imagick time policy');
echo "Worker isolation complete\n";
}finally{proc_terminate($process);proc_close($process);fclose($log);}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/bootstrap.php';
use PrelandAdmin\Config;
use PrelandAdmin\Store;
use PrelandAdmin\Transport;
use PrelandAdmin\Jobs;
function check(bool $value, string $message): void { if (!$value) throw new RuntimeException($message); echo "PASS $message\n"; }
$root = sys_get_temp_dir() . '/preland-admin-' . bin2hex(random_bytes(6));
mkdir($root, 0700, true); mkdir($root . '/site',0700); mkdir($root . '/state',0700);
putenv('ADMIN_STATE_DIR=' . $root . '/state'); putenv('ADMIN_LOCAL_ROOTS=' . $root . '/site');
$initial = (object)['host'=>'zoomzilla.ru','requisites'=>['one'],'custom'=>(object)['flag'=>true,'zero'=>0,'empty'=>(object)[]],'utm'=>(object)[]];
file_put_contents($root . '/site/config.json', json_encode($initial));
$store = new Store($root . '/state');
$target = $store->saveTarget(['id'=>'test','name'=>'Тест','domain'=>'test.invalid','environment'=>'stage','type'=>'local','root'=>$root . '/site','enabled'=>true]);
$store->verifyTarget('test');$target=$store->target('test');
$transport = new Transport($store, $target);
$before = $transport->readConfig();
$next = Config::patch($before['config'], [['path'=>['host'],'value'=>'example.org']]);
check($next->custom->flag === true && $next->custom->zero === 0 && $next->custom->empty instanceof stdClass, 'selected patch preserves unrelated values and types');
$transport->writeConfig($before['version'], Config::encode($next));
$after = $transport->readConfig(); check($after['config']->host === 'example.org','atomic save and reread');
try { $transport->writeConfig($before['version'], Config::encode($initial)); throw new RuntimeException('missed conflict'); } catch (PrelandAdmin\Conflict $e) { check(true,'stale version conflicts'); }
check(count($store->backups('test')) === 1,'private backup saved');
$jobs = new Jobs($store);
$job = $jobs->create('apply', ['targets'=>[['id'=>'test','version'=>$after['version']],['id'=>'missing','version'=>'x']], 'changes'=>[['path'=>['custom','flag'],'value'=>false]]]);
$jobs->run($job['id']); $job=$jobs->get($job['id']);
check($job['results'][0]['status']==='success' && $job['results'][1]['status']==='error','one target failure does not undo successful target');
$restored=$transport->readConfig();
$backup=$store->backups('test')[0];
$job=$jobs->create('restore',['target'=>'test','id'=>$backup['id'],'version'=>$restored['version']]);$jobs->run($job['id']);
check($jobs->get($job['id'])['results'][0]['status']==='success','backup restores with version protection');
try { $store->saveTarget(['id'=>'escape','type'=>'local','root'=>'/etc','domain'=>'a.invalid']); throw new RuntimeException('unsafe root accepted'); } catch (PrelandAdmin\Problem $e) {check(true,'local root confined to explicit mounts');}
try { Config::patch($initial,[['path'=>['__proto__'],'value'=>1]]); throw new RuntimeException('unsafe key accepted'); } catch(PrelandAdmin\Problem $e) {check(true,'unsafe patch path rejected');}
echo "Backend contract complete\n";
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Store,Config,Jobs,Copy,Problem};
function httpCheck(bool $ok,string $label):void{if(!$ok)throw new RuntimeException($label);echo "PASS $label\n";}
$root=sys_get_temp_dir().'/preset-http-'.bin2hex(random_bytes(6));mkdir($root,0700);$store=new Store($root.'/state');$port=random_int(19000,29000);$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_COOKIE_SECURE']='0';$env['ADMIN_USERNAME']='test';$env['ADMIN_PASSWORD_HASH']=password_hash('fixture',PASSWORD_DEFAULT);
$log=fopen($root.'/http.log','a');$server=proc_open([PHP_BINARY,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);$cookie='';
function presetRequest(string $method,string $path,?array $body=null,?string $csrf=null):array{global $port,$cookie;$headers=['Content-Type: application/json'];if($cookie)$headers[]='Cookie: '.$cookie;if($csrf!==null)$headers[]='X-CSRF-Token: '.$csrf;$ctx=stream_context_create(['http'=>['method'=>$method,'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode($body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$raw=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);preg_match('~HTTP/\S+ (\d+)~',$http_response_header[0]??'',$m);foreach($http_response_header as $h)if(str_starts_with(strtolower($h),'set-cookie: '))$cookie=explode(';',substr($h,12))[0];return [(int)($m[1]??0),json_decode((string)$raw,true,64,JSON_THROW_ON_ERROR)];}
try{
$deadline=microtime(true)+5;do{$socket=@fsockopen('127.0.0.1',$port);if($socket){fclose($socket);break;}usleep(20000);}while(microtime(true)<$deadline);
foreach(['GET','POST','DELETE'] as $method){[$status]=presetRequest($method,'/api/utm-presets');httpCheck($status===401,'presets auth '.$method);}
[$status,$login]=presetRequest('POST','/api/login',['username'=>'test','password'=>'fixture']);$csrf=$login['csrf'];
$payload=['name'=>'HTTP набор','utm'=>Config::decode('{"theme":{"default":3,"weights":{"3":100}}}')];
foreach(['POST','DELETE'] as $method){[$status]=presetRequest($method,'/api/utm-presets',$payload);httpCheck($status===403,'presets CSRF '.$method);}
[$status,$saved]=presetRequest('POST','/api/utm-presets',$payload,$csrf);httpCheck($status===201&&array_keys($saved['preset'])===['id','name','utm'],'HTTP creates strictly domain-free snapshot');$id=$saved['preset']['id'];
[$status,$list]=presetRequest('GET','/api/utm-presets');httpCheck($status===200&&$list['presets']===[['id'=>$id,'name'=>'HTTP набор']],'global list without domain');
[$status]=presetRequest('GET','/api/utm-presets?target=anything');httpCheck($status===422,'domain list filter rejected');
[$status]=presetRequest('POST','/api/utm-presets',$payload,$csrf);httpCheck($status===409,'duplicate name conflict');
foreach(['source','domain','host','notes','target'] as $key){[$status]=presetRequest('POST','/api/utm-presets',$payload+[$key=>'forbidden'],$csrf);httpCheck($status===422,'reject preset binding or unrelated field '.$key);}
[$status]=presetRequest('POST','/api/copy',['source'=>(object)['id'=>'old','version'=>str_repeat('a',64)],'targets'=>[]],$csrf);httpCheck($status===422,'legacy domain-copy API rejected');
[$status]=presetRequest('DELETE','/api/utm-presets',['id'=>$id],$csrf);httpCheck($status===200,'delete independent preset');
[$status]=presetRequest('DELETE','/api/utm-presets',['id'=>$id],$csrf);httpCheck($status===404,'repeat deletion returns not found');
[$status,$list]=presetRequest('GET','/api/utm-presets');httpCheck($list['presets']===[],'deleted preset absent from global list');
echo "Preset HTTP contract complete\n";
}finally{proc_terminate($server);proc_close($server);fclose($log);}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/bootstrap.php';
use PrelandAdmin\{LinkBuilder, Problem, Store};
function linkCheck(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
echo "PASS {$message}\n";
}
function linkReject(callable $callback, string $message): void
{
try { $callback(); } catch (Problem) { linkCheck(true, $message); return; }
throw new RuntimeException('Приняты неверные данные: ' . $message);
}
$testRoot = sys_get_temp_dir() . '/link-builder-' . bin2hex(random_bytes(8));
$linkStore = new Store($testRoot);
// Недоступные runtime/SSH: конструктор обязан работать без их чтения.
$linkStore->write('targets.json', [
'first' => ['id'=>'first','domain'=>'first.example','type'=>'sftp','host'=>'unreachable.invalid','root'=>'/not-mounted','secret'=>'fixture-only-secret'],
'second' => ['id'=>'second','domain'=>'second.example','type'=>'local','root'=>'/not-mounted'],
]);
$runtimeFile = $testRoot . '/runtime-fixture.json';
file_put_contents($runtimeFile, '{"host":"unchanged.example","utm":{"indpic":{"default":"1"}}}');
$runtimeBefore = hash_file('sha256', $runtimeFile);
$builder = new LinkBuilder($linkStore);
$empty = ['selection'=>null, 'url'=>null, 'saved_at'=>null];
linkCheck($builder->get('first') === $empty, 'first opening has no implicit saved choice or generated URL');
$selection = ['indpic'=>'9','theme'=>'2','degr'=>'3','reg'=>'1','banch'=>'rnd','tp'=>'4','cons'=>'5','focus'=>'rnd','part'=>'rnd'];
$saved = $builder->save('first', $selection);
linkCheck($saved['selection'] === $selection && is_string($saved['saved_at']), 'explicit save returns complete independent selection and timestamp');
linkCheck(str_starts_with($saved['url'], 'https://first.example/?'), 'URL uses saved target HTTPS origin');
parse_str(parse_url($saved['url'], PHP_URL_QUERY), $parameters);
linkCheck(array_keys($parameters) === array_keys($selection), 'URL contains all nine canonical UTM keys');
foreach ($selection as $key=>$value) {
$digit = $value === 'rnd' ? '0' : $value;
linkCheck((bool)preg_match('/^[a-zA-Z0-9]{4}' . $digit . '[a-zA-Z0-9]+$/D', $parameters[$key]), "{$key} has four ASCII prefix characters, selected digit and noise suffix");
}
linkCheck((new LinkBuilder(new Store($testRoot)))->get('first') === $saved, 'reload preserves exact saved URL and selection without regenerating noise');
$regenerated = $builder->save('first', $selection);
linkCheck($regenerated['selection']===$saved['selection']&&$regenerated['url']!==$saved['url'],'only explicit generation refreshes random noise for unchanged selection');
$saved = $regenerated;
linkCheck($builder->get('second') === $empty, 'other target does not inherit saved selection');
$modified = $saved['selection']; $modified['theme'] = '1';
$updated = $builder->save('first', $modified);
linkCheck($updated['selection']['theme']==='1' && array_diff_assoc($updated['selection'], $selection)===['theme'=>'1'], 'changing one selector preserves all other saved choices');
$builder->save('second', array_fill_keys(array_keys($selection), 'rnd'));
linkCheck($builder->get('first') === $updated, 'saving another target does not overwrite first target state');
$targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed.example';$linkStore->write('targets.json',$targets);
$renamed = $builder->get('first');
linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin');
linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise');
foreach (array_diff(array_keys($selection),['part']) as $key) {
$manualSelection=array_fill_keys(array_keys($selection),'rnd');$manualSelection[$key]='9';
$manual=$builder->save('second',$manualSelection);parse_str(parse_url($manual['url'],PHP_URL_QUERY),$manualParameters);
linkCheck($builder->get('second')===$manual&&$manual['selection'][$key]==='9'&&$manualParameters[$key][4]==='9','manual digit 9 encodes and persists for '.$key);
}
foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'10']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) {
linkReject(fn()=>$builder->save('first',$invalid),'strict selection rejects missing, unknown, wrong-type or injected value');
}
linkReject(fn()=>$builder->get('../targets'),'target traversal rejected');
linkReject(fn()=>$builder->get('missing'),'unknown target rejected');
foreach (['evil.example/path','user@evil.example','https://evil.example','evil.example:443','bad..example','-bad.example',"evil.example\nX: bad"] as $domain) {
$targets['first']['domain']=$domain;$linkStore->write('targets.json',$targets);
linkReject(fn()=>$builder->get('first'),'unsafe registry domain rejected instead of composing executable URL');
}
linkCheck(hash_file('sha256',$runtimeFile)===$runtimeBefore && glob($testRoot.'/jobs/*.json')===[], 'link builder leaves runtime bytes unchanged and creates no jobs');
linkCheck((fileperms($testRoot.'/link-builder-first.json') & 0777)===0600,'saved selection and URL remain in private 0600 state');
echo "Link builder contract complete\n";
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Store,Transport,Jobs,Problem};
function manualCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
foreach(['indpic','theme','degr','reg','banch','tp','cons','focus'] as $key){
$config=Config::decode('{"utm":{"'.$key.'":{"default":"3","weights":{"1":0,"3":100}}}}');
manualCheck(Config::validate($config)===[],'manual variant 3 accepted independently for '.$key);
}
$semantic=Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":100,"light":0},"note":"keep"}}}');
$next=Config::patch($semantic,[['path'=>['utm','theme','weights'],'value'=>(object)['1'=>25,'2'=>25,'3'=>50]],['path'=>['utm','theme','default'],'value'=>'3']]);
Config::validate($next);
manualCheck($next->utm->theme->default==='3'&&get_object_vars($next->utm->theme->weights)===['dark'=>25,'light'=>25,3=>50]&&$next->utm->theme->note==='keep','semantic target retains aliases while adding numeric variant 3');
foreach(['0','10','-1','green'] as $variant){try{Config::validate(Config::decode('{"utm":{"theme":{"default":"'.$variant.'","weights":{}}}}'));throw new RuntimeException('Accepted invalid number');}catch(Problem){manualCheck(true,'invalid variant rejected '.$variant);}}
try{Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":50,"1":50}}}}'));throw new RuntimeException('Accepted duplicate');}catch(Problem){manualCheck(true,'semantic numeric duplicate remains rejected');}
$root=sys_get_temp_dir().'/manual-variants-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'site','domain'=>'site.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('site');file_put_contents($root.'/site/config.json','{"utm":{"theme":{"default":"dark","weights":{"dark":100,"light":0}}}}');$transport=new Transport($store,$store->target('site'));$jobs=new Jobs($store);
$read=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'site','version'=>$read['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>(object)['1'=>0,'2'=>0,'3'=>100]],['path'=>['utm','theme','default'],'value'=>'3'],['path'=>['utm','indpic'],'value'=>(object)['default'=>'9','weights'=>(object)['9'=>100]]]]]);$jobs->run($job['id']);$read=$transport->readConfig();manualCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$read['config']->utm->theme->default==='3'&&$read['config']->utm->indpic->default==='9'&&!is_dir($root.'/site/9'),'apply writes theme3 and indpic9 without probing public files');
$backup=$store->backup('site','{"utm":{"theme":{"default":"3","weights":{"3":100}},"degr":{"default":"9","weights":{"9":100}}}}');$store->write('catalog.json',['host'=>'zoomzilla.ru','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[],'tp'=>[],'cons'=>[]]]);$job=$jobs->create('restore',['target'=>'site','version'=>$read['version'],'id'=>$backup]);$jobs->run($job['id']);manualCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$transport->readConfig()['config']->utm->degr->default==='9','restore accepts manual variants without catalog');
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Store,Transport,Remote,Jobs,Problem};
function obsoleteCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
function obsoleteReject(callable $call,string $message): void {try{$call();}catch(Problem $e){obsoleteCheck($e->status===422&&str_contains($e->getMessage(),'utm.chb'),$message);return;}throw new RuntimeException('Accepted obsolete key: '.$message);}
$legacy='{"host":"zoomzilla.ru","utm":{"chb":null,"unknown":{"raw":true},"cons":{"default":"2","weights":{"1":0,"2":100}}},"custom":{"chb":true}}';
$raw=Config::decode($legacy);obsoleteCheck(property_exists($raw->utm,'chb')&&Config::decode(Config::encode($raw))->custom->chb===true,'legacy raw remains readable and encodable without migration');
obsoleteReject(fn()=>Config::validate($raw),'validation rejects exact deprecated key even null');
foreach([
[['path'=>['host'],'value'=>'other.example']],
[['path'=>['utm','chb'],'value'=>(object)['default'=>'1','weights'=>(object)['1'=>1]]]],
[['path'=>['utm','chb','default'],'value'=>'1']],
[['path'=>['utm'],'value'=>(object)['chb'=>null]]],
] as $changes)obsoleteReject(fn()=>Config::patch($changes[0]['path']===['utm','chb','default']?Config::decode('{"utm":{"chb":{}}}'):$raw,$changes),'patch refuses resulting chb at any depth or parent replacement');
$clean=Config::patch($raw,[['path'=>['utm','chb'],'delete'=>true]]);obsoleteCheck(!property_exists($clean->utm,'chb')&&Config::encode($clean->utm->cons)===Config::encode($raw->utm->cons)&&Config::encode($clean->utm->unknown)===Config::encode($raw->utm->unknown)&&$clean->custom->chb===true,'explicit delete removes only utm.chb and preserves unknown keys and cons');
obsoleteCheck(Config::validate($clean)!==null,'cleaned config passes write validation');
$root=sys_get_temp_dir().'/obsolete-utm-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'a','name'=>'A','domain'=>'a.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('a');$target=$store->target('a');$transport=new Transport($store,$target);file_put_contents($root.'/site/config.json',$legacy);$read=$transport->readConfig();obsoleteCheck($read['content']===$legacy&&property_exists($read['config']->utm,'chb'),'actual transport reads obsolete raw without rewriting');
obsoleteReject(fn()=>$transport->writeConfig($read['version'],$legacy),'direct transport rejects obsolete write');obsoleteCheck(file_get_contents($root.'/site/config.json')===$legacy&&$store->backups('a')===[],'rejected direct transport creates no backup and changes no runtime bytes');
$remotePrivate=$root.'/remote-private';$remote=Remote::execute(['op'=>'write','root'=>$root.'/site','local_private'=>$remotePrivate,'version'=>$read['version'],'content'=>$legacy]);obsoleteCheck(!$remote['ok']&&str_contains($remote['error'],'utm.chb'),'standalone remote rejects obsolete write');obsoleteCheck(!file_exists($remotePrivate)&&file_get_contents($root.'/site/config.json')===$legacy,'remote rejection happens before private directory, backup or temp creation');
$jobs=new Jobs($store);$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['host'],'value'=>'other.example']]]);$jobs->run($job['id']);$result=$jobs->get($job['id'])['results'][0];obsoleteCheck($result['status']==='error'&&str_contains($result['message'],'utm.chb')&&file_get_contents($root.'/site/config.json')===$legacy&&$store->backups('a')===[],'actual apply job rejects retained chb with clear error and no publication/backup');
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['utm','chb'],'delete'=>true]]]);$jobs->run($job['id']);obsoleteCheck($jobs->get($job['id'])['results'][0]['status']==='success','actual explicit delete job succeeds');$after=$transport->readConfig();obsoleteCheck(!property_exists($after['config']->utm,'chb')&&Config::encode($after['config']->utm->cons)===Config::encode($raw->utm->cons),'delete publication preserves cons exactly');
$backups=$store->backups('a');$job=$jobs->create('restore',['target'=>'a','id'=>$backups[0]['id'],'version'=>$after['version']]);$jobs->run($job['id']);$result=$jobs->get($job['id'])['results'][0];obsoleteCheck($result['status']==='error'&&str_contains($result['message'],'utm.chb'),'legacy restore is explicitly rejected, never silently sanitized');obsoleteCheck($transport->readConfig()['content']===$after['content']&&$store->backups('a')===$backups&&$store->backupContent('a',$backups[0]['id'])===$legacy,'rejected restore keeps runtime, backups and historical legacy content exact');
echo "Obsolete UTM write guard contract complete\n";
+36
View File
@@ -0,0 +1,36 @@
<?php
/** api#347: общий редактор, копирование и старое приватное состояние. */
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,Copy,LinkBuilder,Notes,Store,Problem};
function partCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS $message\n";}
$valid=Config::decode('{"host":"keep.example","metadata":{"keep":true},"utm":{"part":{"default":"2","weights":{"1":0,"2":100}},"unknown":{"keep":true}}}');
Config::validate($valid);
foreach (['{"default":"3","weights":{}}','{"default":"1","weights":{"9":1}}','{"default":"1","weights":{"2":-1}}','{"default":"1","weights":{"1":1}}'] as $group) {
$invalid=Config::decode('{"utm":{"part":'.$group.'}}');$rejected=false;
try {Config::validate($invalid);}catch(Problem){$rejected=true;}
partCheck($rejected,'invalid part enum/weights rejected');
}
$project=new ReflectionMethod(Copy::class,'project');$changes=$project->invoke(null,$valid);
$target=Config::decode('{"host":"before.example","notes":"keep","utm":{"part":{"default":"1","weights":{"1":1},"metadata":"keep"},"unknown":{"keep":true}}}');
$patched=Config::patch($target,$changes);
partCheck($patched->utm->part->default==='2'&&$patched->utm->part->weights->{'2'}===100,'copy includes part default and weights');
partCheck($patched->host==='before.example'&&$patched->notes==='keep'&&$patched->utm->part->metadata==='keep'&&$patched->utm->unknown->keep===true,'copy preserves metadata and unknown groups');
$root=sys_get_temp_dir().'/part347-'.bin2hex(random_bytes(6));$store=new Store($root);
$notes=new Notes($store);$old=$notes->all();unset($old['part']);$store->write('utm-notes.json',$old);
$loaded=$notes->all();partCheck(isset($loaded['part'])&&array_diff_key($loaded,['part'=>true])===$old,'old notes gain empty part without losing notes/versions');
$notes->save(['marker'=>'part','text'=>'Видимость ссылки на сайте','version'=>$loaded['part']['version']]);
partCheck($notes->all()['part']['text']==='Видимость ссылки на сайте','part note saves with CAS');
$store->write('targets.json',['fixture'=>['id'=>'fixture','domain'=>'fixture.example']]);
$oldSelection=['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','banch'=>'2','tp'=>'1','cons'=>'2','focus'=>'1'];
$params=[];foreach($oldSelection as $key=>$value)$params[$key]='abcd'.$value.'abcdefabcdef';
$store->write('link-builder-fixture.json',['selection'=>$oldSelection,'url'=>'https://fixture.example/?'.http_build_query($params),'saved_at'=>'2026-10-28T01:00:00+00:00']);
$builder=new LinkBuilder($store);$saved=$builder->get('fixture');
partCheck($saved['selection']===$oldSelection+['part'=>'1'],'old saved builder choice gains default1');
parse_str(parse_url($saved['url'],PHP_URL_QUERY),$newParams);
partCheck(array_intersect_key($newParams,$params)===$params&&$newParams['part'][4]==='1','migration preserves old noise and adds masked part');
partCheck($builder->get('fixture')===$saved,'migrated builder reload preserves exact noise');
foreach(['1','2','rnd'] as $part)partCheck($builder->save('fixture',array_replace($saved['selection'],['part'=>$part]))['selection']['part']===$part,'builder supports part '.$part);
try{$builder->save('fixture',array_replace($saved['selection'],['part'=>'3']));throw new RuntimeException('invalid builder part accepted');}catch(Problem){}
echo "Part admin contract complete\n";
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,PercentMigration,Problem};
function migrationCheck(bool $ok,string $label): void {if(!$ok)throw new RuntimeException($label);echo "PASS {$label}\n";}
function migrationReject(string $raw,string $label): void {try{PercentMigration::patch($raw);}catch(Problem){migrationCheck(true,$label);return;}throw new RuntimeException('Accepted '.$label);}
$raw="{\r\n \"host\":\"untouched.example\", \"requisites\": \"weights:1/3\",\n \"utm\": { \"r\\u0065gistration\":{\"default\": 2, \"weights\": {\"after\": 3e0, \"before\": 1.0}, \"extra\": {\"weights\":{\"x\":7}}},\n \"chob\": {\"weights\":{\"b\":0.8,\"a\":0.2},\"default\":\"b\"},\n \"theme\":{\"default\":\"2\",\"weights\":{\"2\":1,\"1\":0}},\n \"unknown\": {\"weights\":{\"1\":1,\"2\":3},\"nested\":[{\"escaped\\\"key\":\"weights\\\":1\"}]}} ,\"unknown\": 9e+10\r\n}\n";
$expected=str_replace(['"after": 3e0','"before": 1.0','"b":0.8','"a":0.2','"2":1,"1":0'],['"after": 75','"before": 25','"b":80','"a":20','"2":100,"1":0'],$raw);
$migrated=PercentMigration::patch($raw);
migrationCheck($migrated===$expected,'only expected numeric token spans change; every other byte stays exact');
$before=Config::decode($raw);$after=Config::decode($migrated);
foreach(['registration','chob','theme'] as $key){migrationCheck(array_keys(get_object_vars($before->utm->$key->weights))===array_keys(get_object_vars($after->utm->$key->weights)),'variant order and keys preserved '.$key);unset($before->utm->$key->weights,$after->utm->$key->weights);}
migrationCheck($before==$after,'all remaining JSON values/default types/aliases/unknown groups preserved');
migrationCheck(PercentMigration::patch($migrated)===$migrated,'second run is byte-identical');
foreach(['{"x":0,"y":0}','{"x":100.0,"y":0e0}'] as $weights){$source='{"utm":{"bchoice":{"default":1,"weights":'.$weights.'}}}';migrationCheck(PercentMigration::patch($source)===$source,'all-zero and already-percent tokens preserved');}
foreach([['1','3',25,75],['2','1',66.67,33.33],['1e308','1e308',50,50],['1e-300','3e-300',25,75]] as [$x,$y,$a,$b]){$source='{"utm":{"focus":{"weights":{"x":'.$x.',"y":'.$y.'}}}}';$out=Config::decode(PercentMigration::patch($source));migrationCheck((float)$out->utm->focus->weights->x===(float)$a&&(float)$out->utm->focus->weights->y===(float)$b,'safe normalization '.$x.':'.$y);}
foreach([
'{"utm":{"reg":{"weights":{"1":1,"2":1,"3":1}}}}',
'{"utm":{"reg":{"weights":{"1":"1","2":3}}}}',
'{"utm":{"reg":{"weights":{"1":-1,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1e999,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1e-999,"2":3}}}}',
'{"utm":{"reg":{"weights":{"1":1,"1":3}}}}',
'{"utm":{"reg":{"weights":{"1":1,"\\u0031":3}}}}',
'{"utm":{"reg":{"weights":{"1":1}}},"utm":{}}',
'{"utm":{"reg":{"weights":{"1":1}}},"extra":{"x":1,"x":2}}',
'{"utm":{"reg":{"weights":{"1":1}},"registration":{"weights":{"1":1}}}}',
'{"utm":{"theme":{"weights":[1,3]}}}',
'{"utm":null}',
] as $i=>$source)migrationReject($source,'unsafe/ambiguous input rejected '.$i);
echo "Raw percent migration complete\n";
$partRaw='{"metadata":"keep","utm":{"part":{"default":"2","weights":{"1":1,"2":3}}}}';
migrationCheck(PercentMigration::patch($partRaw)===str_replace('{"1":1,"2":3}','{"1":25,"2":75}',$partRaw),'part relative weights normalize only explicit numeric spans');
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Config,PercentWeights,Problem,Store,Transport,Jobs,Copy};
function percentCheck(bool $ok,string $message): void {if(!$ok)throw new RuntimeException($message);echo "PASS {$message}\n";}
function percentReject(callable $call,string $message): void {try{$call();}catch(Problem $e){percentCheck($e->status===422,$message);return;}throw new RuntimeException('Accepted: '.$message);}
function percentConfig(mixed $weights,string $key='theme'): stdClass {return (object)['utm'=>(object)[$key=>(object)['default'=>1,'weights'=>$weights,'extra'=>(object)['keep'=>true]]],'custom'=>(object)['weights'=>[1,2]]];}
foreach(['indpic','theme','degr','reg','registration','banch','chob','bchoice','tp','cons','focus','part'] as $key)foreach([(object)['1'=>100],(object)['1'=>33.33,'2'=>66.67],(object)['1'=>0,'2'=>0],new stdClass()] as $weights)PercentWeights::validate(percentConfig($weights,$key));
percentCheck(true,'all canonical groups, aliases, two decimals and zero groups accepted');
$precisionCases=['{"1":33.33000000000001,"2":66.67}','{"1":99.9999999999999}','{"1":0.010000000000000002,"2":99.99}'];$acceptedPrecision=[];
foreach ($precisionCases as $json) {try {PercentWeights::validate(percentConfig(json_decode($json,false,16,JSON_THROW_ON_ERROR)));$acceptedPrecision[]=$json;echo "FAIL excessive precision accepted {$json}\n";}catch(Problem $e){percentCheck($e->status===422,'strict stored precision rejects '.$json);}}
percentCheck($acceptedPrecision===[],'strict precision does not use binary tolerance to accept extra submitted decimals');
foreach([(object)['1'=>1],(object)['1'=>33.333,'2'=>66.667],(object)['1'=>100.01],(object)['1'=>-1,'2'=>101],(object)['1'=>'100'],(object)['1'=>true],(object)['1'=>null],(object)['1'=>INF],(object)['1'=>NAN],(object)['1'=>50,'2'=>49.99],[],null] as $weights)percentReject(fn()=>PercentWeights::validate(percentConfig($weights)),'invalid stored weights rejected');
percentReject(fn()=>PercentWeights::validate((object)['utm'=>[]]),'invalid utm container rejected');
percentReject(fn()=>PercentWeights::validate((object)['utm'=>(object)['theme'=>null]]),'invalid known group rejected');
percentReject(fn()=>Config::validate(percentConfig((object)['1'=>1])),'Config validation rejects relative weights');
$source=percentConfig((object)['1'=>3,'2'=>1],'registration');$before=Config::encode($source);$migrated=PercentWeights::normalizeRelative($source);PercentWeights::validate($migrated);
percentCheck(get_object_vars($migrated->utm->registration->weights)===['1'=>75,'2'=>25],'explicit migration produces integer percentages');
$rounded=PercentWeights::normalizeRelative(percentConfig((object)['1'=>2,'2'=>1]));percentCheck(get_object_vars($rounded->utm->theme->weights)===['1'=>66.67,'2'=>33.33],'migration rounds each share without redistribution');
percentCheck(Config::encode($source)===$before&&$migrated->utm->registration->default===1&&$migrated->custom==$source->custom,'migration clones and preserves defaults types unknown data');
foreach([[1,1,1],[1.0e-300,1.0e300]] as $values)percentReject(fn()=>PercentWeights::normalizeRelative(percentConfig((object)$values)),'unrepresentable shares stop migration');
foreach([[1.0e308,1.0e308],[1.0e-300,1.0e-300]] as $values){$result=PercentWeights::normalizeRelative(percentConfig((object)$values));percentCheck(array_values(get_object_vars($result->utm->theme->weights))===[50,50],'normalization avoids overflow and underflow');}
$zero=percentConfig((object)['2'=>0,'1'=>0]);percentCheck(Config::encode(PercentWeights::normalizeRelative($zero))===Config::encode($zero),'all-zero order and default preserved');
$root=sys_get_temp_dir().'/percent-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store=new Store($root.'/state');$store->saveTarget(['id'=>'a','name'=>'A','domain'=>'a.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$transport=new Transport($store,$store->target('a'));
$old=Config::encode(percentConfig((object)['1'=>1]));file_put_contents($root.'/site/config.json',$old);percentReject(fn()=>$transport->test(),'connection test refuses relative weights');percentCheck($transport->readConfig()['content']===$old,'read returns old raw without normalization');$store->verifyTarget('a');$transport=new Transport($store,$store->target('a'));$jobs=new Jobs($store);$read=$transport->readConfig();
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$read['version']]],'changes'=>[['path'=>['host'],'value'=>'new.example']]]);$jobs->run($job['id']);percentCheck($jobs->get($job['id'])['results'][0]['status']==='error'&&file_get_contents($root.'/site/config.json')===$old&&$store->backups('a')===[],'apply rejects old weights before publication and backup');
$backup=$store->backup('a',$old);$valid=Config::encode(percentConfig((object)['1'=>100]));file_put_contents($root.'/site/config.json',$valid);$backups=$store->backups('a');$read=$transport->readConfig();$job=$jobs->create('restore',['target'=>'a','version'=>$read['version'],'id'=>$backup]);$jobs->run($job['id']);$out=$jobs->get($job['id'])['results'][0];percentCheck($out['status']==='error'&&str_contains($out['message'],'100')&&file_get_contents($root.'/site/config.json')===$valid&&$store->backups('a')===$backups&&$store->backupContent('a',$backup)===$old,'restore rejects historical relative backup with clear error and retains exact archives');
percentCheck($transport->test()['ok']===true,'valid percentages pass actual connection test');
$saved=$transport->readConfig();$backupCount=count($store->backups('a'));
foreach ([(object)['1'=>33.333,'2'=>66.667],(object)['1'=>99.99],(object)['1'=>'100']] as $weights) {
$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$saved['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>$weights]]]);$jobs->run($job['id']);
percentCheck($jobs->get($job['id'])['results'][0]['status']==='error'&&$transport->readConfig()['content']===$saved['content']&&count($store->backups('a'))===$backupCount,'submitted precision, sum and type errors reject without normalization or publication');
}
$weights=(object)['1'=>33.33,'2'=>66.67];$job=$jobs->create('apply',['targets'=>[['id'=>'a','version'=>$saved['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>$weights]]]);$jobs->run($job['id']);$saved=$transport->readConfig();
percentCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$saved['config']->utm->theme->weights==$weights&&$saved['config']->utm->theme->default===1,'actual apply saves exact submitted percentages and preserves numeric default');
require dirname(__DIR__,2).'/components/utm-codec.php';
foreach(['theme','indpic'] as $key)foreach([(object)['1'=>3,'2'=>1],(object)['2'=>0,'1'=>0]] as $weights){$relative=percentConfig($weights,$key);$percent=PercentWeights::normalizeRelative($relative);mt_srand(2828);$a=[];for($i=0;$i<500;$i++)$a[]=prelandResolveUtm([$key=>'abcd0'],json_decode(Config::encode($relative),true));mt_srand(2828);$b=[];for($i=0;$i<500;$i++)$b[]=prelandResolveUtm([$key=>'abcd0'],json_decode(Config::encode($percent),true));percentCheck($a===$b,'actual seeded runtime resolver invariant across explicit normalization for '.$key);}
echo "Percent weights contract complete\n";
+8
View File
@@ -0,0 +1,8 @@
// Источник старых регрессий заменён независимым UTM-снимком. Доменные поля в POST не передаются.
module.exports=async function choosePreset(page,id){
const state=await page.request.get('/api/state').then(r=>r.json());
const read=await page.request.get('/api/config?target='+id).then(r=>r.json()),utm={};
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const aliases=marker==='reg'?['reg','registration']:marker==='banch'?['banch','chob','bchoice']:[marker];const key=aliases.find(k=>Object.hasOwn(read.config.utm||{},k));if(key)utm[marker]={default:read.config.utm[key].default,weights:read.config.utm[key].weights};}
const response=await page.request.post('/api/utm-presets',{headers:{'X-CSRF-Token':state.csrf},data:{name:'UI-'+Date.now()+'-'+Math.random(),utm}}),saved=await response.json();
if(!saved.ok)throw Error(saved.error);await page.locator('#preset-reload').click();await page.waitForFunction(id=>[...document.querySelector('#copy-source').options].some(o=>o.value===id),saved.preset.id);await page.locator('#copy-source').selectOption(saved.preset.id);return saved.preset.id;
};
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
require dirname(__DIR__).'/bootstrap.php';
function rememberCheck(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo "PASS {$message}\n";}
$rememberRoot=sys_get_temp_dir().'/remember-auth-'.bin2hex(random_bytes(8));mkdir($rememberRoot,0700,true);
$rememberState=$rememberRoot.'/state';$rememberServers=[];
function rememberServer(string $label,?string $state=null): int {
global $rememberRoot,$rememberState,$rememberServers;
$env=getenv();$env['ADMIN_STATE_DIR']=$state??$rememberState;$env['ADMIN_USERNAME']='remember-fixture';$env['ADMIN_PASSWORD_HASH']=password_hash('remember-fixture-password',PASSWORD_DEFAULT);$env['ADMIN_COOKIE_SECURE']='0';$env['PHP_CLI_SERVER_WORKERS']='4';
$port=random_int(19000,29000);$log=fopen($rememberRoot.'/'.$label.'.log','a');$default=$rememberRoot.'/ephemeral-'.$label;mkdir($default,0700);
$p=proc_open([PHP_BINARY,'-d','session.save_path='.$default,'-S','127.0.0.1:'.$port,'-t',dirname(__DIR__).'/public',dirname(__DIR__).'/public/index.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);if(!is_resource($p))throw new RuntimeException('server failed');fclose($pipes[0]);$rememberServers[$label]=[$p,$log];
$until=microtime(true)+5;do{$s=@fsockopen('127.0.0.1',$port);if($s){fclose($s);return $port;}usleep(20000);}while(microtime(true)<$until);throw new RuntimeException('server not ready');
}
function rememberStop(string $label): void {global $rememberServers;if(!isset($rememberServers[$label]))return;[$p,$log]=$rememberServers[$label];proc_terminate($p);proc_close($p);fclose($log);unset($rememberServers[$label]);}
function rememberRequest(int $port,string $path,string $cookie='',?array $body=null,?string $csrf=null): array {
$h=['Content-Type: application/json'];if($cookie!=='')$h[]='Cookie: '.$cookie;if($csrf!==null)$h[]='X-CSRF-Token: '.$csrf;
$ctx=stream_context_create(['http'=>['method'=>$body===null?'GET':'POST','header'=>implode("\r\n",$h),'content'=>$body===null?'':json_encode((object)$body,JSON_THROW_ON_ERROR),'ignore_errors'=>true,'timeout'=>10]]);$text=file_get_contents('http://127.0.0.1:'.$port.$path,false,$ctx);$headers=$http_response_header??[];preg_match('~HTTP/\S+ (\d+)~',$headers[0]??'',$m);
$setCookie=null;foreach($headers as $header)if(str_starts_with(strtolower($header),'set-cookie: preland_admin=')){$setCookie=substr($header,12);$cookie=explode(';',$setCookie)[0];}
clearstatcache();return [(int)($m[1]??0),json_decode((string)$text,true,64,JSON_THROW_ON_ERROR),$cookie,$setCookie];
}
function rememberLogin(int $port,mixed $remember=false,string $cookie=''): array {return rememberRequest($port,'/api/login',$cookie,['username'=>'remember-fixture','password'=>'remember-fixture-password','remember'=>$remember]);}
function rememberFile(string $cookie,bool $remember=true): string {global $rememberState;return $rememberState.'/'.($remember?'remember-sessions':'sessions').'/sess_'.substr($cookie,strlen('preland_admin='));}
function rememberEdit(string $cookie,string $field,int $value): void {$file=rememberFile($cookie);$text=file_get_contents($file);$new=preg_replace('/'.preg_quote($field,'/').'\|i:\d+;/', $field.'|i:'.$value.';', $text,1,$count);if($count!==1)throw new RuntimeException('Missing fixture session field '.$field);file_put_contents($file,$new);}
function rememberBacking(string $cookie): array {
global $rememberState;
$code='require $argv[1];$_COOKIE["preland_admin"]=$argv[2];PrelandAdmin\\Auth::session();echo json_encode(["bucket"=>basename(session_save_path()),"gc"=>(int)ini_get("session.gc_maxlifetime"),"cookie_lifetime"=>session_get_cookie_params()["lifetime"]]);session_write_close();';
$env=getenv();$env['ADMIN_STATE_DIR']=$rememberState;$env['ADMIN_COOKIE_SECURE']='0';$p=proc_open([PHP_BINARY,'-r',$code,dirname(__DIR__).'/bootstrap.php',substr($cookie,strlen('preland_admin='))],[0=>['pipe','r'],1=>['pipe','w'],2=>STDERR],$pipes,null,$env);fclose($pipes[0]);$out=stream_get_contents($pipes[1]);fclose($pipes[1]);if(proc_close($p)!==0)throw new RuntimeException('Backing inspection failed');return json_decode($out,true,64,JSON_THROW_ON_ERROR);
}
try{
$a=rememberServer('a');
[$status]=rememberRequest($a,'/api/state');rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'anonymous request does not create long-lived backing');
[$status,$ignored,$ignoredCookie,$failedCookie]=rememberRequest($a,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck($status===401&&!is_dir($rememberState.'/remember-sessions'),'failed remembered login does not create long-lived backing');
rememberCheck($failedCookie===null,'failed login cannot replace a newer authenticated cookie');
foreach(['true',1,null,[],(object)['value'=>true]] as $bad){[$status]=rememberLogin($a,$bad);rememberCheck($status===422,'remember input requires strict boolean');}
[$status,$regular,$regularCookie,$header]=rememberLogin($a,false);rememberCheck($status===200&&!str_contains(strtolower((string)$header),'expires=')&&!str_contains(strtolower((string)$header),'max-age='),'unchecked login issues browser-session cookie');
$normalFile=rememberFile($regularCookie,false);rememberCheck(is_file($normalFile)&&!is_dir($rememberState.'/remember-sessions'),'regular login remains in short-lived backing');
$backing=rememberBacking($regularCookie);rememberCheck($backing===['bucket'=>'sessions','gc'=>3600,'cookie_lifetime'=>0],'regular backing keeps GC one hour and browser-session lifetime');
$legacy='preland_admin='.bin2hex(random_bytes(16));$legacyCsrf=bin2hex(random_bytes(32));file_put_contents(rememberFile($legacy,false),'user|'.serialize('remember-fixture').'csrf|'.serialize($legacyCsrf).'last|'.serialize(time()));
[$status,$oldState]=rememberRequest($a,'/api/state',$legacy);rememberCheck($status===200&&$oldState['csrf']===$legacyCsrf&&is_file(rememberFile($legacy,false)),'existing d04-style ordinary session continues without migration or deletion');
$before=time();[$status,$login,$cookie,$header]=rememberLogin($a,true,$regularCookie);
rememberCheck($status===200&&preg_match('/expires=([^;]+)/i',(string)$header,$match)===1&&abs(strtotime($match[1])-($before+7776000))<3,'checked login issues native session cookie expiring 90 days from login');
rememberCheck(str_contains(strtolower($header),'httponly')&&str_contains(strtolower($header),'samesite=strict'),'remembered cookie preserves HttpOnly and SameSite Strict');
rememberCheck($cookie!==$regularCookie&&!is_file($normalFile)&&is_file(rememberFile($cookie)),'regular to remember login destroys old backing and creates fresh native SID');
$backing=rememberBacking($cookie);rememberCheck($backing===['bucket'=>'remember-sessions','gc'=>7776000,'cookie_lifetime'=>7776000],'only authenticated remember bucket uses ninety-day GC and cookie lifetime');
[$status]=rememberRequest($a,'/api/state',$regularCookie);rememberCheck($status===401,'old regular cookie cannot be reused after remembered login');
$text=file_get_contents(rememberFile($cookie));rememberCheck(!str_contains($text,'remember-fixture-password')&&!str_contains($text,'password'),'remembered server session stores no password');
preg_match('/remember_until\|i:(\d+);/',$text,$untilMatch);$absolute=(int)($untilMatch[1]??0);rememberCheck($absolute>=$before+7776000&&$absolute<=time()+7776000,'server stores absolute remember-until bound from login');
rememberEdit($cookie,'last',time()-7200);[$status,$state,$same,$refresh]=rememberRequest($a,'/api/state',$cookie);
rememberCheck($status===200&&$state['csrf']===$login['csrf']&&$same===$cookie&&$refresh===null,'remembered session survives old idle timestamp without rotating cookie or CSRF');
preg_match('/remember_until\|i:(\d+);/',file_get_contents(rememberFile($cookie)),$after);rememberCheck((int)$after[1]===$absolute,'ordinary read does not slide absolute remember deadline');
rememberStop('a');$b=rememberServer('b');[$status,$body]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200&&$body['csrf']===$login['csrf'],'remembered login survives fresh server and separate ephemeral session path');
$clients=[];$outputs=[];$code='$ctx=stream_context_create(["http"=>["header"=>"Cookie: ".$argv[2],"ignore_errors"=>true,"timeout"=>5]]);$body=file_get_contents($argv[1],false,$ctx);echo json_encode(["body"=>json_decode($body,true),"set_cookie"=>count(array_filter($http_response_header,fn($h)=>str_starts_with(strtolower($h),"set-cookie:")))]);';
for($i=0;$i<4;$i++){$path=$rememberRoot.'/parallel-'.$i.'.json';$out=fopen($path,'w');$p=proc_open([PHP_BINARY,'-r',$code,'http://127.0.0.1:'.$b.'/api/state',$cookie],[0=>['pipe','r'],1=>$out,2=>STDERR],$pipes);fclose($pipes[0]);$clients[]=[$p,$out];$outputs[]=$path;}
foreach($clients as [$p,$out]){if(proc_close($p)!==0)throw new RuntimeException('Parallel request failed');fclose($out);}foreach($outputs as $path){$parallel=json_decode(file_get_contents($path),true,64,JSON_THROW_ON_ERROR);rememberCheck(($parallel['body']['user']??null)==='remember-fixture'&&($parallel['body']['csrf']??null)===$login['csrf']&&$parallel['set_cookie']===0,'parallel remembered requests preserve authentication, native cookie and CSRF');}
$other=rememberServer('other',$rememberRoot.'/other-state');[$status]=rememberRequest($other,'/api/state',$cookie);rememberCheck($status===401,'remember cookie cannot authenticate against unrelated state');
// Абсолютный срок важнее свежего last; пограничный срок ещё до 90 дней допустим.
rememberEdit($cookie,'remember_until',time()+5);rememberEdit($cookie,'last',time()-7200);[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===200,'remembered session before absolute deadline ignores regular idle expiry');
rememberEdit($cookie,'remember_until',time()-1);rememberEdit($cookie,'last',time());[$status,$body,$ignored,$expiredHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&!is_file(rememberFile($cookie))&&$expiredHeader===null,'absolute expiry revokes backing without sending a cookie that can erase a newer login');
[$status,$ignored,$ignoredCookie,$replayedHeader]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401&&$replayedHeader===null&&!is_file(rememberFile($cookie)),'expired cookie remains unauthenticated on replay after server revocation');
[$status,$login,$cookie]=rememberLogin($b,true);$longFile=rememberFile($cookie);[$status,$newLogin,$newCookie,$header]=rememberLogin($b,false,$cookie);rememberCheck($status===200&&$newCookie!==$cookie&&!is_file($longFile)&&is_file(rememberFile($newCookie,false))&&!str_contains(strtolower((string)$header),'expires='),'remember to regular login destroys long-lived SID and restores session-only cookie');
[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'old remembered cookie cannot be reused after regular login');
$normal=rememberFile($newCookie,false);$raw=file_get_contents($normal);file_put_contents($normal,preg_replace('/last\|i:\d+;/','last|i:'.(time()-3601).';',$raw));[$status]=rememberRequest($b,'/api/state',$newCookie);rememberCheck($status===401,'unchecked login still enforces one-hour idle expiry');
[$status,$login,$cookie]=rememberLogin($b,true);$file=rememberFile($cookie);[$status]=rememberRequest($b,'/api/logout',$cookie,[],'invalid');rememberCheck($status===403&&is_file($file),'remembered logout requires existing CSRF');
[$status,$body,$ignored,$clear]=rememberRequest($b,'/api/logout',$cookie,[],$login['csrf']);rememberCheck($status===200&&!is_file($file)&&str_contains(strtolower((string)$clear),'expires=')&&str_contains(strtolower((string)$clear),'httponly')&&str_contains(strtolower((string)$clear),'samesite=strict'),'logout immediately destroys remembered SID and clears same cookie flags');
[$status]=rememberRequest($b,'/api/state',$cookie);rememberCheck($status===401,'captured pre-logout remembered cookie cannot be replayed');
$longCount=count(glob($rememberState.'/remember-sessions/sess_*')?:[]);for($i=0;$i<3;$i++)rememberRequest($b,'/api/state','preland_admin='.bin2hex(random_bytes(16)));rememberCheck(count(glob($rememberState.'/remember-sessions/sess_*')?:[])===$longCount,'arbitrary anonymous SIDs create no 90-day files');
rememberRequest($b,'/api/login','',['username'=>'remember-fixture','password'=>'wrong','remember'=>true]);rememberCheck(count(glob($rememberState.'/remember-sessions/sess_*')?:[])===$longCount,'failed remembered login creates no long-lived files in existing bucket');
rememberCheck((fileperms($rememberState.'/remember-sessions')&0777)===0700&&(fileperms($rememberState.'/sessions')&0777)===0700,'both backing directories keep private 0700 modes');
[$status,$normal,$normalCookie]=rememberLogin($b,false);$normalPath=rememberFile($normalCookie,false);$link=rememberFile($normalCookie,true);symlink($normalPath,$link);[$status]=rememberRequest($b,'/api/state',$normalCookie);rememberCheck($status===503&&is_file($normalPath),'remembered SID symlink is rejected without reading regular backing through it');unlink($link);
[$status,$fresh,$freshCookie]=rememberLogin($b,true,$normalCookie);rememberCheck((fileperms(rememberFile($freshCookie))&0777)===0600,'authenticated remember file has private 0600 mode');
mkdir($rememberRoot.'/external',0700);mkdir($rememberRoot.'/unsafe',0700);symlink($rememberRoot.'/external',$rememberRoot.'/unsafe/remember-sessions');$unsafe=rememberServer('unsafe',$rememberRoot.'/unsafe');[$status]=rememberRequest($unsafe,'/api/state');rememberCheck($status===503&&glob($rememberRoot.'/external/*')===[],'remember-directory symlink fails closed without external writes');
echo "Remember auth contract complete\n";
}finally{foreach(array_keys($rememberServers) as $label)rememberStop($label);}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/bootstrap.php';
function sessionCheck(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
echo "PASS {$message}\n";
}
$fixture = sys_get_temp_dir() . '/persistent-session-' . bin2hex(random_bytes(8));
mkdir($fixture, 0700, true);
mkdir($fixture . '/shared', 0700);
mkdir($fixture . '/shared/sessions', 0777);chmod($fixture . '/shared/sessions', 0777);
$passwordHash = password_hash('persistent-session-fixture-password', PASSWORD_DEFAULT);
$servers = [];
function startSessionServer(string $state, string $label): array
{
global $fixture, $passwordHash, $servers;
$ephemeral = $fixture . '/ephemeral-' . $label;mkdir($ephemeral, 0700);
$environment = getenv();
unset($environment['PHP_CLI_SERVER_WORKERS']);
$environment['ADMIN_STATE_DIR'] = $state;
$environment['ADMIN_USERNAME'] = 'session-fixture';
$environment['ADMIN_PASSWORD_HASH'] = $passwordHash;
$environment['ADMIN_COOKIE_SECURE'] = '0';
$port = random_int(19000, 29000);
$log = fopen($fixture . '/' . $label . '.log', 'a');
$process = proc_open([PHP_BINARY, '-d', 'session.save_path=' . $ephemeral, '-S', '127.0.0.1:' . $port,
'-t', dirname(__DIR__) . '/public', dirname(__DIR__) . '/public/index.php'],
[0=>['pipe','r'], 1=>$log, 2=>$log], $pipes, null, $environment);
if (!is_resource($process)) throw new RuntimeException('Не удалось запустить PHP server fixture.');
fclose($pipes[0]);
$servers[$label] = [$process, $log];
$deadline = microtime(true) + 5;
do {
$socket = @fsockopen('127.0.0.1', $port);
if ($socket) { fclose($socket); return ['port'=>$port, 'label'=>$label]; }
usleep(20000);
} while (microtime(true) < $deadline);
throw new RuntimeException('PHP server fixture не отвечает.');
}
function stopSessionServer(string $label): void
{
global $servers;
if (!isset($servers[$label])) return;
[$process, $log] = $servers[$label];
proc_terminate($process);proc_close($process);fclose($log);unset($servers[$label]);
}
function sessionRequest(array $server, string $path, string $cookie = '', ?array $body = null, ?string $csrf = null): array
{
$headers = ['Content-Type: application/json'];
if ($cookie !== '') $headers[] = 'Cookie: ' . $cookie;
if ($csrf !== null) $headers[] = 'X-CSRF-Token: ' . $csrf;
$context = stream_context_create(['http'=>['method'=>$body===null?'GET':'POST',
'header'=>implode("\r\n",$headers),'content'=>$body===null?'':json_encode((object)$body, JSON_THROW_ON_ERROR),
'ignore_errors'=>true,'timeout'=>10]]);
$text = file_get_contents('http://127.0.0.1:' . $server['port'] . $path, false, $context);
$responseHeaders = $http_response_header ?? [];
preg_match('~HTTP/\S+ (\d+)~', $responseHeaders[0] ?? '', $match);
foreach ($responseHeaders as $header) if (str_starts_with(strtolower($header), 'set-cookie: ')) $cookie=explode(';',substr($header,12))[0];
return [(int)($match[1]??0), json_decode((string)$text,true,64,JSON_THROW_ON_ERROR), $cookie];
}
try {
$a = startSessionServer($fixture . '/shared', 'a');
[$status, $login, $cookie] = sessionRequest($a, '/api/login', '', ['username'=>'session-fixture','password'=>'persistent-session-fixture-password']);
sessionCheck($status===200 && isset($login['csrf']), 'server A logs in and returns session cookie');
stopSessionServer('a');
$b = startSessionServer($fixture . '/shared', 'b');
[$status, $body] = sessionRequest($b, '/api/state', $cookie);
sessionCheck($status===200 && $body['user']==='session-fixture' && $body['csrf']===$login['csrf'], 'fresh server B with separate ephemeral default path restores same cookie from shared state');
sessionCheck((fileperms($fixture.'/shared/sessions')&0777)===0700, 'persistent session directory has private 0700 permissions');
parse_str(str_replace(';','&',$cookie), $cookieValues);$sessionId=$cookieValues['preland_admin'];
$sessionFile=$fixture.'/shared/sessions/sess_'.$sessionId;
sessionCheck(is_file($sessionFile), 'session data exists inside ADMIN_STATE_DIR sessions');
sessionCheck((fileperms($sessionFile)&0777)===0600, 'native persistent session file has private 0600 permissions');
$c = startSessionServer($fixture . '/isolated', 'c');
[$status] = sessionRequest($c, '/api/state', $cookie);
sessionCheck($status===401, 'same cookie cannot authenticate fresh server with different state');
stopSessionServer('c');
[$status] = sessionRequest($b, '/api/logout', $cookie, [], 'wrong-token');
sessionCheck($status===403, 'persisted session still rejects invalid CSRF');
[$status] = sessionRequest($b, '/api/logout', $cookie, [], $login['csrf']);
sessionCheck($status===200, 'persisted session logs out with its original CSRF');
[$status] = sessionRequest($b, '/api/state', $cookie);
sessionCheck($status===401, 'logout invalidates original persistent cookie');
[$status, $newLogin, $newCookie] = sessionRequest($b, '/api/login', '', ['username'=>'session-fixture','password'=>'persistent-session-fixture-password']);
sessionCheck($status===200 && $newCookie!==$cookie && $newLogin['csrf']!==$login['csrf'], 'new login regenerates session ID and CSRF after persistent logout');
parse_str($newCookie, $values);$expiredFile=$fixture.'/shared/sessions/sess_'.$values['preland_admin'];
$expired=preg_replace('/last\|i:\d+;/', 'last|i:'.(time()-3601).';', file_get_contents($expiredFile));file_put_contents($expiredFile,$expired);
[$status] = sessionRequest($b, '/api/state', $newCookie);
sessionCheck($status===401, 'persistent file does not extend the existing 3600-second idle expiry');
mkdir($fixture.'/external',0700);mkdir($fixture.'/symlink-session',0700);symlink($fixture.'/external',$fixture.'/symlink-session/sessions');
$bad = startSessionServer($fixture.'/symlink-session','bad-session');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503 && glob($fixture.'/external/*')===[], 'symlink sessions directory fails closed without writing outside state');stopSessionServer('bad-session');
symlink($fixture.'/shared',$fixture.'/state-link');
$bad = startSessionServer($fixture.'/state-link','bad-state');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503, 'symlink state root fails closed');stopSessionServer('bad-state');
$bad = startSessionServer($fixture.'/state-link/nested','bad-ancestor');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503 && !file_exists($fixture.'/shared/nested'), 'symlink state ancestor rejected before creating nested directory');stopSessionServer('bad-ancestor');
$publicState=dirname(__DIR__).'/public/session-state-'.bin2hex(random_bytes(6));
$bad = startSessionServer($publicState,'bad-public');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503 && !file_exists($publicState), 'public webroot state path rejected before creating any directory');stopSessionServer('bad-public');
$bad = startSessionServer('relative-session-state','bad-relative');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503, 'relative state path fails closed');stopSessionServer('bad-relative');
file_put_contents($fixture.'/file-state','not-a-directory');
$bad = startSessionServer($fixture.'/file-state','bad-file');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503, 'unusable state path returns safe HTTP 503');stopSessionServer('bad-file');
mkdir($fixture.'/file-session',0700);file_put_contents($fixture.'/file-session/sessions','not-a-directory');
$bad = startSessionServer($fixture.'/file-session','bad-session-file');[$status]=sessionRequest($bad,'/api/state');
sessionCheck($status===503, 'regular file in place of sessions directory returns safe HTTP 503');stopSessionServer('bad-session-file');
echo "Session persistence contract complete\n";
} finally {
foreach (array_keys($servers) as $label) stopSessionServer($label);
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require __DIR__.'/backend.php';
function targetProblem(callable $action,int $status,string $message): void {
try {$action();throw new RuntimeException($message.': accepted');}
catch(PrelandAdmin\Problem $e){check($e->status===$status,$message);}
}
$legacy=$store->read('targets.json');
unset($legacy['test']['hidden']);$store->write('targets.json',$legacy);
check($store->targets()[0]['hidden']===false,'legacy target is visible by default');
check($store->target('test')===$legacy['test'],'legacy raw target identity remains unchanged');
foreach(['b','c','d'] as $id)$store->saveTarget(['id'=>$id,'domain'=>$id.'.invalid','type'=>'local','root'=>$root.'/site']);
$store->setTargetVisibility('b',true);
$before=$store->target('b');
check($before['hidden']===true,'visibility persists independently of transport');
$unchanged=$legacy['test'];$store->setTargetVisibility('test',true);$afterVisibility=$store->target('test');unset($afterVisibility['hidden']);
check($afterVisibility===$unchanged,'visibility changes no credentials enabled verified or transport fields');
$store->setTargetVisibility('test',false);
$store->saveTarget(['id'=>'b','hidden'=>false]);
check($store->target('b')['hidden']===true,'editing connection cannot override hidden state');
$store->saveTarget(['id'=>'new','domain'=>'new.invalid','type'=>'local','root'=>$root.'/site','hidden'=>true]);
check($store->target('new')['hidden']===false,'new connection is visible and appended');
$store->orderTargets(['d','test','new','c']);
check(array_column((new PrelandAdmin\Store($root.'/state'))->targets(),'id')===['d','b','test','new','c'],'reorder persists while preserving hidden slot');
$store->setTargetVisibility('b',false);
check(array_column($store->targets(),'id')===['d','b','test','new','c'],'restore returns target to preserved slot');
$unchanged=$store->read('targets.json');
foreach([['d','b','test','new'],['d','b','test','new','new'],['d','b','test','new','missing']] as $ids)targetProblem(fn()=>$store->orderTargets($ids),409,'stale incomplete duplicate or unknown order is rejected');
check($store->read('targets.json')===$unchanged,'rejected reorder does not alter persisted state');
targetProblem(fn()=>$store->orderTargets(['d',1,'test','new','c']),422,'order requires string IDs');
targetProblem(fn()=>$store->setTargetVisibility('missing',true),404,'visibility missing target returns 404');
targetProblem(fn()=>$store->deleteTarget('missing'),404,'delete missing target returns 404');
targetProblem(fn()=>$store->deleteTarget('b'),409,'visible target cannot be deleted');
$store->setTargetVisibility('b',true);
$job=$jobs->create('check',['targets'=>['b'],'number'=>1]);
targetProblem(fn()=>$store->deleteTarget('b'),409,'queued target cannot be deleted');
$rawJob=$store->read('jobs/'.$job['id'].'.json');$rawJob['status']='running';$store->write('jobs/'.$job['id'].'.json',$rawJob);
targetProblem(fn()=>$store->deleteTarget('b'),409,'running target cannot be deleted');
check($store->target('b')['root']===$before['root'],'hiding and rejected deletion retain usable target transport');
$rawJob['status']='completed';$store->write('jobs/'.$job['id'].'.json',$rawJob);
$job=$jobs->create('copy',['source'=>['id'=>'b'],'targets'=>[['id'=>'test']]]);
targetProblem(fn()=>$store->deleteTarget('b'),409,'queued copy source cannot be deleted');
$rawJob=$store->read('jobs/'.$job['id'].'.json');$rawJob['status']='completed';$store->write('jobs/'.$job['id'].'.json',$rawJob);
$store->deleteTarget('b');
check(array_column($store->targets(),'id')===['d','test','new','c'],'hidden idle target deletes only its connection');
check(is_file($root.'/site/config.json'),'connection deletion keeps site files');
foreach(['d','test','new','c'] as $id)$store->setTargetVisibility($id,true);
$store->orderTargets([]);
check(array_column($store->targets(),'id')===['d','test','new','c'],'empty visible order preserves all hidden connections');
echo "Targets Store contract complete\n";
+97
View File
@@ -0,0 +1,97 @@
<?php
/** #22: сервер разрывает SSH exec длиннее 8 KiB; helper и payload не теряются. */
declare(strict_types=1);
namespace phpseclib3\Net {
class SSH2 {
public static array $commands=[];
public static bool $withoutZlib=false;
public static bool $dropInbox=false;
public function __construct(...$args) {}
public function getServerPublicHostKey(): string {return 'ssh-ed25519 '.base64_encode('fixture-key');}
public function login(...$args): bool {return true;}
public function setTimeout($seconds): void {}
public function isTimeout(): bool {return false;}
public function exec(string $command): string|false {
self::$commands[]=$command;
if(strlen($command)>8192)return false; // фактическая граница REG.RU, старая реализация падает
if(self::$dropInbox&&str_contains($command,'/inbox-'))return false;
if(self::$withoutZlib)$command=preg_replace('/^php /','php -d disable_functions=gzinflate ',$command);
$process=proc_open($command,[0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
fclose($pipes[0]);$output=stream_get_contents($pipes[1]);$error=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);proc_close($process);
if($error!=='')throw new \RuntimeException($error);
return $output;
}
}
class SFTP extends SSH2 {
public static array $uploads=[];
public function put(string $path,string $bytes): bool {self::$uploads[]=$path;return file_put_contents($path,$bytes)!==false;}
public function chmod(int $mode,string $path): bool {return chmod($path,$mode);}
public function delete(string $path): bool {return !file_exists($path)||unlink($path);}
}
}
namespace {
require dirname(__DIR__).'/bootstrap.php';
use PrelandAdmin\{Store,Transport,Problem};
use phpseclib3\Net\{SSH2,SFTP};
function ensure(bool $condition,string $message): void {if(!$condition)throw new RuntimeException($message);echo 'PASS: '.$message."\n";}
$fixture=sys_get_temp_dir().'/preland22-command-'.bin2hex(random_bytes(6));mkdir($fixture);mkdir($fixture.'/site');
$site=$fixture.'/site';file_put_contents($site.'/config.json','{"host":"initial.example","utm":{}}');
$store=new Store($fixture.'/state');
$target=['id'=>'limit','type'=>'sftp','host'=>'fixture','port'=>22,'user'=>'fixture','secret'=>'fixture','host_key'=>'ssh-ed25519 '.base64_encode('fixture-key'),'root'=>$site];
$transport=new Transport($store,$target);
try {
$transport->test();$before=$transport->readConfig();
ensure($before['config']->host==='initial.example','compressed helper reads under SSH 8KiB limit');
$transport->writeConfig($before['version'],'{"host":"small.example","utm":{}}');
ensure($transport->readConfig()['config']->host==='small.example','compressed RPC performs CAS write/read');
$large=json_encode(['host'=>'large.example','utm'=>(object)[],'padding'=>str_repeat('abcdefgh',3000)],JSON_THROW_ON_ERROR);
$before=$transport->readConfig();$transport->writeConfig($before['version'],$large);
ensure($transport->readConfig()['content']===$large,'large payload uses inbox and preserves bytes');
ensure(count(SFTP::$uploads)===1,'large payload uploaded through SFTP exactly once');
foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'SFTP inbox removed after RPC');
$maximum=max(array_map('strlen',SSH2::$commands));
ensure($maximum<=7000,'all final shell commands stay within 7000 bytes (maximum '.$maximum.')');
$commandCount=count(SSH2::$commands);$uploads=count(SFTP::$uploads);
// Inline test может оказаться длиннее inbox runner из-за base64 длинного
// root, даже когда сам inbox помещается: этот preflight нельзя рекурсировать.
$longRootTarget=$target;$longRootTarget['root']='/'.str_repeat('a',1800);
try{(new Transport($store,$longRootTarget))->test();throw new RuntimeException('oversized test recursed');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'SSH bootstrap'),'oversized test RPC rejected before inbox/preflight recursion');}
ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized test performs no exec/upload');
$bootstrap=new ReflectionMethod(Transport::class,'inboxCommand');
try{$bootstrap->invoke(null,'/*'.base64_encode(random_bytes(16000)).'*/',$site.'/inbox-test');throw new RuntimeException('oversized bootstrap accepted');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'bootstrap'),'oversized helper rejected before recursive preflight');}
ensure(count(SSH2::$commands)===$commandCount&&count(SFTP::$uploads)===$uploads,'oversized bootstrap performs no remote call/upload');
$before=$transport->readConfig();SSH2::$dropInbox=true;
try{$transport->writeConfig($before['version'],$large);throw new RuntimeException('dropped exec accepted');}
catch(Problem $error){ensure(str_contains($error->getMessage(),'Неизвестный результат'),'dropped inbox exec remains unknown result');}
SSH2::$dropInbox=false;
foreach(SFTP::$uploads as $path)ensure(!file_exists($path),'inbox cleaned even when exec is interrupted');
SSH2::$withoutZlib=true;
try{$transport->test();throw new RuntimeException('missing zlib accepted');}catch(Problem $error){ensure(str_contains($error->getMessage(),'zlib'),'missing gzinflate returns clear structured error');}
SSH2::$withoutZlib=false;
// Настоящие 90 изображений: два слоя base64 дают аргумент заметно больше PHP shell-limit 2 MiB.
$sourceSet=dirname(__DIR__,2).'/1';$files=[];
foreach(array_keys(\PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $filename){$bytes=file_get_contents($sourceSet.'/'.$filename);$files[$filename]=['data'=>base64_encode($bytes),'sha256'=>hash('sha256',$bytes)];}
$hugePayload=['op'=>'set-write','number'=>3,'files'=>$files,'expected_manifest'=>null];
ensure(count($files)===90&&strlen(base64_encode(json_encode($hugePayload,JSON_THROW_ON_ERROR)))>2097152,'real 90-image set-write payload exceeds native 2 MiB shell argument limit');
$uploadsBeforeHuge=count(SFTP::$uploads);$configBeforeHuge=$transport->readConfig();
$transport->call($hugePayload);
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===\PrelandAdmin\Remote::manifest($sourceSet),'multi-MiB set-write activates all 90 real files with exact hashes');
ensure(count(SFTP::$uploads)===$uploadsBeforeHuge+1,'multi-MiB set-write uses exactly one SFTP inbox');
$hugeInbox=SFTP::$uploads[array_key_last(SFTP::$uploads)];
ensure(!file_exists($hugeInbox),'multi-MiB set-write inbox removed after actual RPC');
ensure(max(array_map('strlen',SSH2::$commands))<=7000,'multi-MiB set-write final exec stays below bootstrap limit');
ensure($transport->readConfig()['version']===$configBeforeHuge['version'],'image upload preserves config and the 2 MiB config contract');
// Обрыв до исполнения большой команды не должен трогать опубликованный набор; inbox удаляется.
$hugePayload['expected_manifest']=\PrelandAdmin\Remote::manifest($site.'/3');$keptManifest=$hugePayload['expected_manifest'];SSH2::$dropInbox=true;
try{$transport->call($hugePayload);throw new RuntimeException('dropped multi-MiB set-write accepted');}
catch(Problem $error){ensure($error->status===504,'interrupted multi-MiB set-write remains unknown, not false success');}
finally{SSH2::$dropInbox=false;}
ensure(\PrelandAdmin\Remote::manifest($site.'/3')===$keptManifest,'interrupted multi-MiB exec preserves previously active set');
ensure(!file_exists(SFTP::$uploads[array_key_last(SFTP::$uploads)]),'interrupted multi-MiB set-write cleans private inbox');
} finally {
$iterator=new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fixture,FilesystemIterator::SKIP_DOTS),RecursiveIteratorIterator::CHILD_FIRST);
foreach($iterator as $file)$file->isDir()?rmdir($file->getPathname()):unlink($file->getPathname());rmdir($fixture);
}
}
+46
View File
@@ -0,0 +1,46 @@
/* Approved overview + independent link builder, preland#22/issue3767.
* Automated Docker QA; production CUA verification is owned by the lead.
* No mocked routes; constructor must never write config or create jobs. */
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});
const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});
const errors=[],mutations=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());
page.on('request',r=>{if(r.method()!=='GET'&&/\/api\/(apply|restore|sets\/|jobs\/retry)/.test(r.url()))mutations.push(r.url());});
try{
await page.goto(process.env.ADMIN_URL||'http://admin');
assert.match(await page.locator('script[src*="admin.js"]').getAttribute('src'),/^\/assets\/admin[.]js[?]v=[a-f0-9]{12}$/);
assert.match(await page.locator('link[href*="admin.css"]').getAttribute('href'),/^\/assets\/admin[.]css[?]v=[a-f0-9]{12}$/);
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();
await page.locator('#workspace').waitFor({state:'visible'});
assert.equal(await page.locator('#workspace > section').first().getAttribute('id'),'overview','saved runtime overview is first section');
await page.locator('#overview-table tbody tr').first().waitFor();assert.equal(await page.locator('#overview-table tbody tr[data-overview-utm]').count(),9);await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0);
const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;};
const before=await Promise.all(['a','b'].map(read));
await page.locator('#builder-target').selectOption('a');
await page.locator('#builder-fields select[data-marker=theme]').waitFor();
const generate=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/link-builder')&&r.request().method()==='POST');await page.getByRole('button',{name:'Сформировать и сохранить',exact:true}).click();const result=await(await response).json();assert.ok(result.ok,result.error);await page.waitForFunction(url=>document.querySelector('#builder-result').hidden===false&&document.querySelector('#builder-url').value===url,result.url);return result.url;};
const expectedTheme='rnd';await page.locator('#builder-fields select[data-marker=theme]').selectOption(expectedTheme);
const first=await generate();assert.equal(new URL(first).searchParams.get('theme')[4],'0');
const selectionA=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(selectionA.selection.theme,'rnd');assert.equal(selectionA.url,first);
const nextReg=selectionA.selection.reg==='rnd'?'1':'rnd';await page.locator('#builder-fields select[data-marker=reg]').selectOption(nextReg);
assert.equal(await page.locator('#builder-result').isVisible(),false,'old saved URL hidden for unsaved constructor changes');
const updated=await generate();const saved=await page.request.get('/api/link-builder?target=a').then(r=>r.json());assert.equal(saved.selection.reg,nextReg);for(const marker of Object.keys(selectionA.selection))if(marker!=='reg')assert.equal(saved.selection[marker],selectionA.selection[marker]);assert.notEqual(updated,first);
await page.locator('#source-target').selectOption('a');await page.getByLabel('Основной сайт a',{exact:true}).waitFor();await page.getByLabel('Основной сайт a',{exact:true}).fill('draft-only.example.org');
await page.locator('#read-state').getByText('Домен прочитан.',{exact:true}).waitFor();const version=await page.locator('#read-state').innerText();
await page.getByRole('button',{name:'Обновить настройки',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);
assert.equal(await page.getByLabel('Основной сайт a',{exact:true}).inputValue(),'draft-only.example.org');assert.equal(await page.locator('#read-state').innerText(),version);assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/Есть изменения/);
assert.equal((await read('a')).config.host,before[0].config.host,'overview/constructor did not save editor draft');
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-desktop.png',fullPage:true});
await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await page.locator('#builder-target').selectOption('a');await page.locator('#builder-url').waitFor({state:'visible'});assert.equal(await page.locator('#builder-url').inputValue(),updated);assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),'rnd');assert.equal(await page.locator('#builder-fields select[data-marker=reg]').inputValue(),nextReg);
await page.locator('#builder-target').selectOption('b');await page.locator('#builder-fields select[data-marker=theme]').waitFor();const persistedB=await page.request.get('/api/link-builder?target=b').then(r=>r.json());if(persistedB.selection)assert.equal(await page.locator('#builder-fields select[data-marker=theme]').inputValue(),persistedB.selection.theme);
const after=await Promise.all(['a','b'].map(read));for(let i=0;i<2;i++){assert.equal(after[i].version,before[i].version);assert.deepEqual(after[i].config,before[i].config);}
assert.deepEqual(mutations,[],'no config/job/sets mutations from overview or constructor');assert.deepEqual(errors,[]);
await page.setViewportSize({width:390,height:844});await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/overview-builder-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/overview-builder-evidence.json',JSON.stringify({configVersions:after.map(r=>r.version),selection:saved.selection,url:saved.url,mutations,pageErrors:errors},null,2));
console.log('PASS real overview / independent constructor / saved selection URL reload / no config writes / editor isolation / desktop mobile');
}finally{await browser.close();}
})().catch(error=>{console.error(error);process.exit(1);});
+8
View File
@@ -0,0 +1,8 @@
const choosePreset=require('./preset-ui-helper.cjs');
/* preland#30: native manual config editing; Browser plugin not available. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b','d'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(7000);const cell=(id,m)=>page.locator('[data-matrix-target='+id+'][data-marker='+m+']'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}),cancel=id=>page.getByRole('button',{name:'Отменить настройки '+id,exact:true}),read=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const settled=async id=>page.waitForFunction(id=>/^(Прочитано с хостинга|Записано на хостинг)$/.test(document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent),id,{timeout:60000});const add=async(id,m,n)=>{await cell(id,m).getByRole('button',{name:'Добавить вариант '+m+' '+id,exact:true}).click();const number=cell(id,m).getByLabel('Новый вариант '+m+' '+id,{exact:true}).last();assert.equal(await number.inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' вес новый '+id,{exact:true}).last().inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' default новый '+id,{exact:true}).last().isChecked(),false);assert.equal(await save(id).isDisabled(),true);await number.fill('0');assert.equal(await save(id).isDisabled(),true);await number.fill(n);await cell(id,m).getByLabel(m+' вес '+n+' '+id,{exact:true}).last().fill('0');};try{const semantic=read('a');semantic.utm.theme={default:'dark',weights:{dark:100,light:0}};semantic.utm.focus={default:'1',weights:{'2':100}};fs.writeFileSync(root+'/a/config.json',JSON.stringify(semantic));const d=read('d');delete d.utm.cons;fs.writeFileSync(root+'/d/config.json',JSON.stringify(d));await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await settled(id);
for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus']){await add('a',m,'9');assert.equal(await cell('a',m).getByLabel(m+' вес 9 a',{exact:true}).inputValue(),'0');assert.equal(await cell('a',m).getByLabel(m+' default 9 a',{exact:true}).isChecked(),false);}await save('a').click();await settled('a');for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus'])assert.equal(read('a').utm[m].weights['9'],0,'new zero variant must persist '+m);
assert.equal(Object.hasOwn(read('a').utm.focus.weights,'1'),false,'synthetic missing default row must not be materialized by a manual zero addition');await add('a','theme','9');assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/уникальны/);await cell('a','theme').getByLabel('Новый вариант theme a',{exact:true}).last().fill('3');await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).check();await save('a').click();await settled('a');assert.equal(read('a').utm.theme.default,'3');assert.equal(read('a').utm.theme.weights['3'],0);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await settled('a');assert.equal(await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).isChecked(),true);
await add('d','cons','3');assert.equal(await save('d').isDisabled(),true,'new group requires explicit default');await cell('d','cons').getByLabel('cons default 3 d',{exact:true}).check();await save('d').click();await settled('d');assert.deepEqual(read('d').utm.cons,{default:'3',weights:{'3':0}});
await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,null,{timeout:60000});assert.equal(read('b').utm.theme.default,'3');assert.equal(read('b').utm.theme.weights['3'],0);assert.equal(read('b').utm.focus.weights['9'],0);await add('a','theme','8');await cancel('a').click();assert.equal(await cell('a','theme').getByLabel('theme вес 8 a',{exact:true}).count(),0);await page.locator('#builder-target').selectOption('a');await page.getByLabel('Ссылка: theme',{exact:true}).selectOption('3');await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.equal((await page.request.get('/api/link-builder?target=a').then(r=>r.json())).selection.theme,'3');assert.doesNotMatch(await page.locator('#builder-warnings').innerText(),/каталог/);await page.locator('#overview').screenshot({path:out+'/manual-variants-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/manual-variants-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS all8 manual variants, zero Save/reread, theme3/default/builder, duplicate recovery, missing group/default, cancel, mobile, no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
+41
View File
@@ -0,0 +1,41 @@
const choosePreset=require('./preset-ui-helper.cjs');
/* preland#25: real UI, actual CAS/jobs and isolated fixture. Browser plugin not available. */
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');const fs=require('node:fs');
(async()=>{
const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);let csrf='';
const host=id=>page.locator('[data-matrix-target="'+id+'"] input[data-host]'),cell=(id,m)=>page.locator('[data-matrix-target="'+id+'"][data-marker="'+m+'"]'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}),cancel=id=>page.getByRole('button',{name:'Отменить настройки '+id,exact:true}),header=id=>page.locator('[data-overview-target="'+id+'"]');
const request=async(route,data)=>{const response=data===undefined?await page.request.get('/api/'+route):await page.request.post('/api/'+route,{data,headers:{'X-CSRF-Token':csrf}});const result=await response.json();assert.ok(result.ok,result.error);return result;};
const get=id=>request('config?target='+id);const readFile=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const writeFile=(id,c)=>fs.writeFileSync(root+'/'+id+'/config.json',JSON.stringify(c));
const saved=async id=>{await page.waitForFunction(id=>document.querySelector('[data-overview-target="'+id+'"] .matrix-status').textContent.match(/^(Прочитано с хостинга|Записано на хостинг)$/),id,{timeout:60000});};
const refresh=async()=>{await page.locator('#overview-refresh').click();await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);};
try{
const d=readFile('d');delete d.utm.cons;writeFile('d',d);
await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await saved(id);csrf=(await request('state')).csrf;
assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.matrix-read-at').count(),4);assert.equal(await page.locator('#overview').getByText(/requisites|chb/).count(),0);
const weights=cell('a','theme').locator('input[type=number]');assert.equal(await page.locator('.matrix-percent').count(),0);assert.equal(await weights.first().getAttribute('min'),'0');assert.equal(await weights.first().getAttribute('max'),'100');assert.equal(await weights.first().getAttribute('step'),'0.01');assert.match(await cell('a','theme').locator('.matrix-labels').innerText(),/Вес, %/);
const defaultBefore=readFile('a').utm.theme.default;await weights.nth(0).fill('33.33');assert.equal(await weights.nth(1).inputValue(),'75','typing never redistributes other weights');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/сумма/);await weights.nth(1).fill('66.67');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 100%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':33.33,'2':66.67});assert.equal(readFile('a').utm.theme.default,defaultBefore);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await weights.first().inputValue(),'33.33');
await weights.first().fill('33.333');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('0.00000000001');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('-1');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('101');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/от 0 до 100/);await weights.first().fill('');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('0');await weights.nth(1).fill('0');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 0%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':0,'2':0});assert.equal(readFile('a').utm.theme.default,defaultBefore);await weights.first().fill('25');await weights.nth(1).fill('75');await save('a').click();await saved('a');
assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] .matrix-remove').count(),0);
assert.equal(await cell('d','cons').locator('.matrix-variant').count(),0);assert.equal(await cell('d','cons').locator('.matrix-add-variant').count(),1);assert.equal(readFile('d').utm.cons,undefined);
const beforeDefault=readFile('a');await cell('a','theme').locator('input[type=radio]').first().check();await save('a').click();await saved('a');assert.equal(await header('a').locator('.matrix-status').innerText(),'Записано на хостинг');assert.deepEqual(readFile('a').utm.theme.weights,beforeDefault.utm.theme.weights);assert.equal(readFile('a').utm.theme.default,'1');assert.equal(typeof readFile('a').utm.theme.default,typeof beforeDefault.utm.theme.default);await cell('a','theme').locator('input[type=radio]').nth(1).check();await save('a').click();await saved('a');assert.deepEqual(readFile('a'),beforeDefault);
console.log('PASS edit/default/missing'); // Two independent drafts and note cursor remain through foreign-column save and read.
await host('a').fill('draft-a.invalid');const handle=await host('a').elementHandle();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});await cell('b','theme').locator('input[type=number]').first().fill('70');await cell('b','theme').locator('input[type=number]').nth(1).fill('30');await save('b').click();await saved('b');assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);assert.equal(readFile('b').utm.theme.weights.dark,70);assert.equal(readFile('b').utm.theme.default,'light');assert.deepEqual(readFile('b').custom,JSON.parse(original.get('b')).custom);assert.deepEqual(readFile('b').requisites,JSON.parse(original.get('b')).requisites);await refresh();assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);await cancel('a').click();
console.log('PASS foreign save'); // A late force GET must not erase input typed after reload starts.
let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);let held=false;const delayed=async route=>{if(!held){held=true;const response=await route.fetch();started();await gate;await route.fulfill({response});}else await route.continue();};await page.route('**/api/config?target=a',delayed);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click({noWaitAfter:true});await seen;await host('a').fill('after-get.invalid');release();await page.waitForTimeout(200);assert.equal(await host('a').inputValue(),'after-get.invalid');await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();assert.doesNotMatch(await page.locator('#copy-reason').innerText(),/после ошибки чтения/,'successful late read must finish its loading snapshot');await page.locator('#copy-none').click();await page.unroute('**/api/config?target=a',delayed);await cancel('a').click();
console.log('PASS late get'); // Real remote version conflict, explicit reload and offline recovery preserve drafts.
await host('a').fill('my-draft.invalid');const external=readFile('a');external.custom.external='concurrent';writeFile('a',external);await save('a').click();await header('a').getByText(/измен|верс|конфликт/i).waitFor();assert.equal(await host('a').inputValue(),'my-draft.invalid');await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await host('a').inputValue(),external.host);
await cell('b','theme').locator('input[type=number]').first().fill('50');await cell('b','theme').locator('input[type=number]').nth(1).fill('50');await context.setOffline(true);await save('b').click();await header('b').getByText(/Связь/).waitFor();assert.equal(await cell('b','theme').locator('input[type=number]').first().inputValue(),'50');await context.setOffline(false);await save('b').click();await saved('b');assert.equal(readFile('b').utm.theme.weights.dark,50);
console.log('PASS conflict/offline'); // Source and recipient drafts block copying. Copy selected recipients only, semantic aliases preserved.
await choosePreset(page,'a');assert.equal(await page.getByLabel('Копировать в a.invalid',{exact:true}).count(),1);await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.getByLabel('Копировать в c.invalid',{exact:true}).check();await host('a').fill('source-draft.invalid');assert.equal(await page.locator('#copy-submit').isEnabled(),true,'independent preset ignores original domain drafts');await cancel('a').click();await host('b').fill('target-draft.invalid');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/получателей/);await cancel('b').click();
const beforeD=fs.readFileSync(root+'/d/config.json','utf8'),beforeB=readFile('b'),source=(await get('a')).config;await page.locator('#copy-submit').click();assert.equal(await page.locator('#copy-all').isDisabled(),true);assert.equal(await page.locator('#copy-none').isDisabled(),true);await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});assert.equal(await page.locator('#copy-results .status-success').count(),2);await saved('b');await saved('c');assert.equal(await header('b').locator('.matrix-status').innerText(),'Записано на хостинг');assert.equal(await header('c').locator('.matrix-status').innerText(),'Записано на хостинг');const copiedB=readFile('b');assert.deepEqual(copiedB.utm.theme.weights,{dark:source.utm.theme.weights['1'],light:source.utm.theme.weights['2']});assert.deepEqual(copiedB.requisites,beforeB.requisites);assert.deepEqual(copiedB.custom,beforeB.custom);assert.equal(fs.readFileSync(root+'/d/config.json','utf8'),beforeD);
// Real target CAS partial failure and source CAS preflight failure.
const staleC=readFile('c');staleC.custom.concurrent='target';writeFile('c',staleC);const partialResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();const partial=(await(await partialResponse).json()).job;await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});const partialJob=(await request('jobs?id='+partial.id)).job;assert.deepEqual(partialJob.results.map(r=>r.status),['success','conflict']);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.equal(await page.locator('#copy-results .status-conflict').count(),1);assert.equal(readFile('c').custom.concurrent,'target');await header('c').getByRole('button',{name:'Обновить настройки c',exact:true}).click();await saved('c');const staleSource=readFile('a');staleSource.custom.concurrent='source';writeFile('a',staleSource);const sourceResponse=page.waitForResponse(r=>r.url().endsWith('/api/copy')&&r.request().method()==='POST');await page.locator('#copy-submit').click();assert.equal((await sourceResponse).status(),202);await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,{timeout:60000});assert.equal(await page.locator('#copy-results .status-success').count(),2);await header('a').getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');console.log('PASS actual partial copy and source version conflict'); await page.locator('#copy-none').click();assert.equal(await page.locator('#copy-targets input:checked').count(),0);await page.locator('#copy-all').click();assert.equal(await page.locator('#copy-targets input:checked').count(),4);await page.locator('#copy-none').click();
console.log('PASS copy'); // Operations keep their own selection independent from copy UI.
await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();await choosePreset(page,'c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['a','b']);await page.locator('#source-target').selectOption('c');assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(nodes=>nodes.map(n=>n.value)),['c']);await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();
const unchangedBeforeZip=(await get('a')).version;await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Invalid ZIP #25');await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not ZIP')});const zipResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));await page.locator('#set-add button').click();const invalidZip=await(await zipResponse).json();if(invalidZip.ok){await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.locator('#result-body').getByText(/Ошибка/).waitFor({timeout:60000});await page.getByRole('button',{name:'Готово',exact:true}).click();}else await page.locator('#notice.alert-danger').waitFor();assert.equal((await get('a')).version,unchangedBeforeZip);console.log('PASS actual invalid ZIP preserves runtime'); await page.locator('#builder-target').selectOption('a');await page.locator('#builder-fields select').first().waitFor();await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.match(await page.locator('#builder-url').inputValue(),/^https:\/\/a.invalid/);
// Notes preserve the same textarea node, caret and draft during matrix refresh.
const note=page.locator('[data-utm-note=reg]');await note.getByRole('button',{name:/(Добавить|Редактировать) заметку reg/}).click();const textarea=note.getByLabel('Заметка reg',{exact:true});await textarea.fill('Заметка #25\nПроверка курсора');const noteHandle=await textarea.elementHandle();await textarea.evaluate(n=>{n.focus();n.setSelectionRange(3,7);document.querySelector('#overview-refresh').click();});await page.waitForFunction(()=>!document.querySelector('#overview-refresh').disabled);assert.deepEqual(await noteHandle.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:3,end:7});await note.getByRole('button',{name:'Отменить заметку reg',exact:true}).click();
await page.locator('#overview').screenshot({path:out+'/matrix-desktop.png'});await page.locator('#copy-panel').screenshot({path:out+'/copy-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/matrix-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.locator('#copy-panel').screenshot({path:out+'/copy-mobile.png'});assert.deepEqual(errors,[]);console.log('PASS inline weights/default/static numbers/absent/save/cancel; multi drafts/late GET/CAS/offline; saved copy selected-only/aliases/unknown preservation; independent operations/builder/notes/mobile/no JS errors');
}finally{await context.setOffline(false);await browser.close();for(const [id,content]of original)fs.writeFileSync(root+'/'+id+'/config.json',content);}
})().catch(e=>{console.error(e);process.exit(1);});
+44
View File
@@ -0,0 +1,44 @@
/* preland#23: real compact overview and CAS notes. Runtime configs/jobs stay unchanged. */
const {chromium}=require('playwright-core');const assert=require('node:assert/strict');const fs=require('node:fs');
(async()=>{
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const context=await browser.newContext({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const page=await context.newPage();page.on('dialog',d=>d.accept());const errors=[],consoleMessages=[];page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))consoleMessages.push({type:m.type(),text:m.text()});});
const output=process.env.UI_OUTPUT||'/tmp';let initialNote=null,csrf='',primaryError=null;
const request=async(route,data)=>{const response=data===undefined?await page.request.get('/api/'+route):await page.request.post('/api/'+route,{data,headers:{'X-CSRF-Token':csrf}});const result=await response.json();assert.ok(result.ok,result.error);return result;};
const row=()=>page.locator('tr[data-utm-note=reg]'),textarea=()=>row().getByLabel('Заметка reg',{exact:true});
const enterEdit=async()=>{await row().getByRole('button',{name:/(?:Добавить|Редактировать) заметку reg/}).click();await textarea().waitFor();};
const withDelayedNotesGet=async operation=>{
let release,ready;const gate=new Promise(resolve=>release=resolve),captured=new Promise(resolve=>ready=resolve);let held=false;
const handler=async route=>{if(route.request().method()==='GET'&&!held){held=true;const response=await route.fetch();ready();await gate;await route.fulfill({response});}else await route.continue();};
await page.route('**/api/utm-notes',handler);
try{await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);await page.evaluate(()=>document.querySelector('#overview-refresh').click());await Promise.race([captured,new Promise((_,reject)=>setTimeout(()=>reject(Error('GET notes was not captured')),5000))]);await operation();release();await page.waitForFunction(()=>![...document.querySelectorAll('tr[data-utm-note] [role=status]')].some(n=>n.textContent.includes('Загрузка заметок')));}
finally{release();await page.unroute('**/api/utm-notes',handler);}
};
const postStartThenOldGet=async()=>{
let releasePost,releaseGet,postReady,getReady;const postGate=new Promise(resolve=>releasePost=resolve),getGate=new Promise(resolve=>releaseGet=resolve),postSeen=new Promise(resolve=>postReady=resolve),getSeen=new Promise(resolve=>getReady=resolve);let heldPost=false,heldGet=false;
const handler=async route=>{if(route.request().method()==='POST'&&!heldPost){heldPost=true;postReady();await postGate;await route.continue();}else if(route.request().method()==='GET'&&!heldGet){heldGet=true;const response=await route.fetch();getReady();await getGate;await route.fulfill({response});}else await route.continue();};
await page.route('**/api/utm-notes',handler);
try{await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);const posted=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();await Promise.race([postSeen,new Promise((_,reject)=>setTimeout(()=>reject(Error('POST notes was not captured')),5000))]);await page.evaluate(()=>document.querySelector('#overview-refresh').click());await Promise.race([getSeen,new Promise((_,reject)=>setTimeout(()=>reject(Error('GET during POST was not captured')),5000))]);releasePost();const response=await(await posted).json();assert.ok(response.ok,response.error);await textarea().waitFor({state:'hidden'});releaseGet();await page.waitForFunction(()=>![...document.querySelectorAll('tr[data-utm-note] [role=status]')].some(n=>n.textContent.includes('Загрузка заметок')));}
finally{releasePost();releaseGet();await page.unroute('**/api/utm-notes',handler);}
};
const save=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();const body=await(await response).json();assert.ok(body.ok,body.error);await textarea().waitFor({state:'hidden'});return body.note;};
try{
await page.goto(process.env.ADMIN_URL||'http://admin');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor({state:'visible'});
await page.locator('#overview-table tr[data-overview-utm=reg] td .matrix-total').first().waitFor({timeout:6000});
for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const header=page.locator('tr[data-utm-note='+marker+']'),data=page.locator('tr[data-overview-utm='+marker+']');assert.equal(await header.locator('th').innerText(),marker);assert.equal(await data.locator('th').innerText(),'');assert.equal(await data.evaluate(n=>n.previousElementSibling.dataset.utmNote),marker);}
const state=await request('state');csrf=state.csrf;const snapshots=await Promise.all(state.targets.map(async t=>{const r=await page.request.get('/api/config?target='+t.id).then(r=>r.json());return {id:t.id,...r};}));const jobsBefore=(await request('jobs')).jobs.map(j=>j.id).sort();
const enumMap={theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},banch:{b:'1',a:'2'},focus:{face:'1',center:'2'}};
const alias={reg:['registration'],banch:['chob','bchoice']};
for(let index=0;index<snapshots.length;index++){const snapshot=snapshots[index];if(!snapshot.ok)continue;for(const marker of ['indpic','theme','degr','reg','banch','tp','cons','focus','part']){const config=snapshot.config.utm||{},key=[marker,...(alias[marker]||[])].find(k=>Object.hasOwn(config,k));if(!key)continue;const group=config[key],items=Object.entries(group.weights||{}).map(([v,w])=>[enumMap[marker]?.[v]||v,w]);const sum=items.reduce((n,[,w])=>n+w,0),def=enumMap[marker]?.[group.default]||String(group.default);if(!items.some(([v])=>v===def))items.push([def,0]);const cell=page.locator('#overview-table tr[data-overview-utm='+marker+'] td').nth(index),expected=items.map(([,w])=>String(w));assert.deepEqual(await cell.locator('input[type=number]').evaluateAll(ns=>ns.map(n=>n.value)),expected);assert.equal(await cell.locator('.matrix-percent').count(),0);assert.equal(await cell.locator('.matrix-total').innerText(),'Сумма: '+sum+'%');assert.equal(await cell.locator('input[type=radio]:checked').count(),1);for(const label of await cell.locator('.matrix-default').all())assert.ok(await label.evaluate(n=>Number(getComputedStyle(n).fontWeight))<=400,'default normal weight');}} await page.locator('#overview-table tr[data-overview-utm=indpic] td').first().screenshot({path:output+'/owner-format-cell.png'});const notes=await request('utm-notes');initialNote=notes.notes.reg;await row().waitFor();assert.equal(Number(await row().locator('td').getAttribute('colspan')),state.targets.length);assert.equal(await row().locator('td').evaluate(n=>getComputedStyle(n).position),'static');await page.locator('#overview').screenshot({path:output+'/overview-desktop.png'});
await enterEdit();const draft='Заметка UI — '+Date.now()+'\n<img src=x onerror=alert(1)>\nТолько обычный текст';await textarea().fill(draft);const handle=await textarea().elementHandle();await textarea().evaluate(n=>{document.querySelector('#overview-refresh').click();n.focus();n.setSelectionRange(7,11);});await page.waitForFunction(()=>document.querySelector('#overview-refresh').disabled===false);assert.equal(await textarea().inputValue(),draft);assert.deepEqual(await handle.evaluate(n=>({connected:n.isConnected,focused:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focused:true,start:7,end:11});
await save();assert.equal((await request('utm-notes')).notes.reg.text,draft);assert.equal(await row().locator('img').count(),0);await page.reload();await page.locator('#workspace').waitFor({state:'visible'});await row().getByText(draft,{exact:true}).waitFor();
const newer=draft+'\nСохранено после начала старого GET';
await withDelayedNotesGet(async()=>{await enterEdit();await textarea().fill(newer);await save();});
await row().getByText(newer,{exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,newer,'late GET cannot roll back successful Save');
const postFirst=newer+'\nPOST начат раньше GET';await enterEdit();await textarea().fill(postFirst);await postStartThenOldGet();await row().getByText(postFirst,{exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,postFirst,'GET started during Save cannot roll back committed result');
await enterEdit();const conflictDraft=postFirst+'\nМой конфликтный черновик';await textarea().fill(conflictDraft);const current=(await request('utm-notes')).notes.reg;const external='Внешняя заметка '+Date.now();await request('utm-notes',{marker:'reg',text:external,version:current.version});
const conflictResponse=page.waitForResponse(r=>r.url().endsWith('/api/utm-notes')&&r.request().method()==='POST');await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();assert.equal((await conflictResponse).status(),409);assert.equal(await textarea().inputValue(),conflictDraft);await row().getByRole('button',{name:'Загрузить сохранённую заметку reg',exact:true}).waitFor();assert.equal((await request('utm-notes')).notes.reg.text,external);await withDelayedNotesGet(async()=>{const latest=(await request('utm-notes')).notes.reg;const finalExternal=external+' — новая версия';await request('utm-notes',{marker:'reg',text:finalExternal,version:latest.version});await row().getByRole('button',{name:'Загрузить сохранённую заметку reg',exact:true}).click();await page.waitForFunction(text=>document.querySelector('textarea[aria-label="Заметка reg"]').value===text,finalExternal);});assert.equal(await textarea().inputValue(),external+' — новая версия','late GET cannot roll back explicit server reload');
const networkDraft=external+'\nВосстановление связи';await textarea().fill(networkDraft);await context.setOffline(true);await row().getByRole('button',{name:'Сохранить заметку reg',exact:true}).click();await row().locator('[role=status]').getByText(/Связь|соединени|ошибк/i).waitFor();assert.equal(await textarea().inputValue(),networkDraft);await context.setOffline(false);await save();assert.equal((await request('utm-notes')).notes.reg.text,networkDraft);
await page.screenshot({path:output+'/notes-desktop.png',fullPage:true});await page.setViewportSize({width:390,height:844});await enterEdit();await textarea().fill('Мобильный черновик\nНесколько строк');await page.screenshot({path:output+'/notes-mobile.png',fullPage:true});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.equal(await textarea().evaluate(n=>getComputedStyle(n).resize),'vertical');await row().getByRole('button',{name:'Отменить заметку reg',exact:true}).click();
for(const before of snapshots){const after=await page.request.get('/api/config?target='+before.id).then(r=>r.json());if(before.ok){assert.ok(after.ok,after.error);assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config);}}assert.deepEqual((await request('jobs')).jobs.map(j=>j.id).sort(),jobsBefore);assert.deepEqual(errors,[]);fs.writeFileSync(output+'/notes-evidence.json',JSON.stringify({configVersions:snapshots.filter(r=>r.ok).map(r=>({id:r.id,version:r.version})),jobsUnchanged:true,pageErrors:errors,console:consoleMessages},null,2));console.log('PASS actual compact percentages / notes input-save-reload / cursor survives refresh / CAS conflict explicit reload / offline recovery / desktop mobile / runtime jobs unchanged');
}catch(error){primaryError=error;}finally{const cleanup=[];try{await context.setOffline(false);if(initialNote){if(!(await textarea().isVisible()))await enterEdit();await textarea().fill(initialNote.text);const latest=(await request('utm-notes')).notes.reg;await request('utm-notes',{marker:'reg',text:initialNote.text,version:latest.version});}}catch(error){cleanup.push(error);}try{await browser.close();}catch(error){cleanup.push(error);}if(primaryError&&cleanup.length)throw new AggregateError([primaryError,...cleanup],'notes scenario failed; separate cleanup errors',{cause:primaryError});if(primaryError)throw primaryError;if(cleanup.length)throw new AggregateError(cleanup,'notes cleanup failed');}
})().catch(error=>{console.error(error);process.exit(1);});
+7
View File
@@ -0,0 +1,7 @@
const choosePreset=require('./preset-ui-helper.cjs');
/* preland#28: actual stored percentages, strict inline validation and saved Copy gates. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());const cell=id=>page.locator('[data-matrix-target='+id+'][data-marker=theme]'),weight=id=>cell(id).locator('input[type=number]'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true});const reload=async id=>{await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await page.waitForFunction(id=>!document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent.includes('Чтение'),id);};const invalid=id=>{const c=JSON.parse(original.get(id)),weights=c.utm.theme.weights;for(const key of Object.keys(weights))weights[key]=0;weights[Object.keys(weights)[0]]=1;fs.writeFileSync(root+'/'+id+'/config.json',JSON.stringify(c));};try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(n=>!n.textContent.includes('Чтение')));await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true);
invalid('a');await reload('a');assert.equal(await weight('a').first().inputValue(),'1','GET must show stored 1%, without relative conversion');assert.equal(await cell('a').locator('.matrix-total').innerText(),'Сумма: 1%');assert.match(await cell('a').locator('.matrix-validation').innerText(),/100%.*0%/);assert.equal(await save('a').isDisabled(),true);assert.equal(await page.locator('#copy-submit').isEnabled(),true,'saved preset independent from changed origin');await page.getByRole('button',{name:'Сохранить UTM-набор a.invalid',exact:true}).click();assert.equal(await page.locator('#preset-dialog').isVisible(),false);assert.match(await page.locator('#notice').innerText(),/сумма/);await page.getByLabel('Основной сайт a',{exact:true}).fill('draft-percent.invalid');assert.equal(await save('a').isDisabled(),true,'unrelated host edit cannot publish an invalid untouched weight group');await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();assert.match(await cell('a').locator('.matrix-validation').innerText(),/сумма/,'cancel retains invalid saved config warning');await page.locator('#overview').screenshot({path:out+'/percent-invalid-saved.png'});fs.writeFileSync(root+'/a/config.json',original.get('a'));await reload('a');assert.equal(await page.locator('#copy-submit').isEnabled(),true);
invalid('b');await reload('b');assert.equal(await page.locator('#copy-submit').isDisabled(),true);assert.match(await page.locator('#copy-reason').innerText(),/Получатель:.*сумма/);fs.writeFileSync(root+'/b/config.json',original.get('b'));await reload('b');assert.equal(await page.locator('#copy-submit').isEnabled(),true);
const before=JSON.parse(original.get('a'));await weight('a').first().click();await weight('a').first().press('ControlOrMeta+A');await weight('a').first().pressSequentially('33.33');assert.equal(await weight('a').nth(1).inputValue(),String(before.utm.theme.weights['2']));assert.equal(await save('a').isDisabled(),true);await weight('a').nth(1).fill('66.67');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a').locator('input[type=radio]:checked').getAttribute('aria-label'),'theme default '+before.utm.theme.default+' a');await save('a').click();await page.waitForFunction(()=>document.querySelector('[data-overview-target=a] .matrix-status').textContent==='Записано на хостинг',null,{timeout:60000});await reload('a');const actual=JSON.parse(fs.readFileSync(root+'/a/config.json','utf8')),expected=structuredClone(before);expected.utm.theme.weights={'1':33.33,'2':66.67};assert.deepEqual(actual,expected);assert.equal(await weight('a').first().inputValue(),'33.33');assert.equal(await weight('a').nth(1).inputValue(),'66.67');await page.locator('#overview').screenshot({path:out+'/percent-saved-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.screenshot({path:out+'/percent-mobile.png',fullPage:false});assert.deepEqual(errors,[]);console.log('PASS raw stored1%/inline sum; saved invalid source+target Copy blocking/recovery; unrelated edits blocked; native typing 33.33+66.67 Save+reread/default+other fields preserved; desktop/mobile/no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)});
+6
View File
@@ -0,0 +1,6 @@
/* preland#29: actual rendered read lifecycle and immutable UTM identifiers. Browser plugin not available. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));const header=id=>page.locator('[data-overview-target="'+id+'"]'),reload=id=>page.getByRole('button',{name:'Обновить настройки '+id,exact:true});const idle=()=>page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(n=>!n.textContent.includes('Чтение')));try{await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await idle();
assert.equal(await page.locator('[data-overview-utm] select').count(),0,'UTM numbers must be static');assert.equal(await page.locator('[data-overview-utm] .matrix-remove').count(),0,'UTM variants cannot be removed');assert.equal(await page.locator('[data-overview-utm] .matrix-add-variant').count(),32,'manual add on every UTM/domain');assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);const date=header('a').locator('.matrix-read-at');assert.match(await date.innerText(),/Последнее чтение: .*\d{2}:\d{2}:\d{2} МСК/);const before=await date.innerText(),foreign=await header('b').locator('.matrix-read-at').innerText();let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);const delay=async route=>{const response=await route.fetch();started();await gate;await route.fulfill({response});};await page.route('**/api/config?target=a',delay);await reload('a').click();await seen;assert.equal(await reload('a').isDisabled(),true);assert.match(await header('a').locator('.matrix-status').innerText(),/Чтение с хостинга/);assert.equal(await date.innerText(),before);await page.waitForTimeout(1100);release();await page.waitForFunction(()=>!document.querySelector('[data-overview-target=a] .matrix-status').textContent.includes('Чтение'));await page.unroute('**/api/config?target=a',delay);assert.equal(await reload('a').isEnabled(),true);assert.equal(await header('b').locator('.matrix-read-at').innerText(),foreign);const success=await date.innerText();
const fail=route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({ok:false,error:'Ошибка чтения fixture #29'})});await page.route('**/api/config?target=a',fail);await reload('a').click();await header('a').getByText(/Ошибка чтения fixture/).waitFor();assert.equal(await date.innerText(),success);assert.equal(await reload('a').isEnabled(),true);await page.unroute('**/api/config?target=a',fail);await page.waitForTimeout(1100);await reload('a').click();await idle();assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);assert.notEqual(await date.innerText(),success);
const host=page.getByLabel('Основной сайт a',{exact:true});await host.fill('draft-29.invalid');page.once('dialog',d=>d.dismiss());await reload('a').click();assert.equal(await host.inputValue(),'draft-29.invalid');page.once('dialog',d=>d.accept());await reload('a').click();await idle();assert.notEqual(await host.inputValue(),'draft-29.invalid');assert.deepEqual(errors,[]);await page.locator('#overview').screenshot({path:'/output/read-desktop.png'});await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:'/output/read-mobile.png'});console.log('PASS static UTM / actual GET loading+disabled/date / error retains date / retry / foreign date / dirty dismiss+confirm / desktop/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+3
View File
@@ -0,0 +1,3 @@
/* preland#25 consolidates partial copy/CAS/offline/ZIP-error regressions in the
* actual new matrix flow. This compatibility entry executes that full scenario. */
require('./ui-matrix.cjs');
+39
View File
@@ -0,0 +1,39 @@
/* preland#22/comment3770. Real local Docker HTTP + persistent Chromium profile.
* Cookie values/passwords are never written to evidence. */
const {chromium}=require('playwright-core');
const assert=require('node:assert/strict');
const fs=require('node:fs');const os=require('node:os');const path=require('node:path');
(async()=>{
const profile=fs.mkdtempSync(path.join(os.tmpdir(),'preland-remember-'));
const origin=process.env.ADMIN_URL||'http://admin',output=process.env.UI_OUTPUT||'/tmp';
const launch=()=>chromium.launchPersistentContext(profile,{executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox'],baseURL:origin,viewport:{width:1440,height:1000}});
let context=await launch();const errors=[];let page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
try{
await page.goto(origin);
const remember=page.getByLabel('Запомнить меня на 90 дней',{exact:true});
assert.equal(await remember.count(),1,'remember checkbox exists');assert.equal(await remember.isChecked(),false,'unchecked HTML default');
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await remember.check();
const loginRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await loginRequest).postDataJSON().remember,true,'explicit boolean true');
await page.locator('#workspace').waitFor({state:'visible'});
const cookies=await context.cookies(origin),now=Date.now()/1000;
const durable=cookies.find(c=>process.env.REMEMBER_COOKIE?c.name===process.env.REMEMBER_COOKIE:c.httpOnly&&c.expires>now+86400*60);
assert.ok(durable,'persistent HttpOnly remember cookie');assert.ok(Math.abs((durable.expires-now)-90*86400)<600,'expires approximately 90 days');assert.equal(durable.httpOnly,true);assert.equal(durable.sameSite,'Strict');
const target=process.env.TARGET_ID||'a';
const read=async()=>{const r=await page.request.get('/api/config?target='+target).then(r=>r.json());assert.ok(r.ok,r.error);return r;};const before=await read();
assert.ok(Array.isArray(before.config.requisites)&&before.config.requisites.length,'fixture requisites required');
await page.locator('#overview-table tr[data-overview-extra=host]').waitFor();assert.equal(await page.locator('#overview-table tr[data-overview-extra=requisites]').count(),0,'requisites overview hidden');
await page.locator('#source-target').selectOption(target);await page.getByLabel('Основной сайт '+target,{exact:true}).waitFor();
assert.equal(await page.locator('#config-fields').getByLabel('Реквизиты',{exact:true}).count(),0,'requisites editor hidden');
assert.equal(await page.locator('#config-fields input[aria-label^="Применить поле requisites"]').count(),0,'requisites absent from editable fields');
assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0,'old generic editor removed');
const after=await read();assert.equal(after.version,before.version);assert.deepEqual(after.config,before.config,'runtime unchanged');
await page.screenshot({path:output+'/remember-requisites-editor.png',fullPage:true});
await context.close();context=await launch();page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto(origin);await page.locator('#workspace').waitFor({state:'visible'});assert.equal(await page.locator('#login-panel').isVisible(),false,'reopen authenticates without password');
await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'});
await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByLabel('Запомнить меня на 90 дней',{exact:true}).uncheck();
const shortRequest=page.waitForRequest(r=>r.url().endsWith('/api/login')&&r.method()==='POST');await page.getByRole('button',{name:'Войти',exact:true}).click();assert.equal((await shortRequest).postDataJSON().remember,false,'explicit boolean false');await page.locator('#workspace').waitFor({state:'visible'});
const shortCookies=await context.cookies(origin);assert.ok(!shortCookies.some(c=>c.name===durable.name&&c.expires>Date.now()/1000+86400),'unchecked login does not retain 90-day cookie');
assert.deepEqual(errors,[]);fs.writeFileSync(output+'/remember-evidence.json',JSON.stringify({cookie:{name:durable.name,expires:durable.expires,httpOnly:durable.httpOnly,sameSite:durable.sameSite,secure:durable.secure},configVersion:after.version,pageErrors:errors,reopenAuthenticated:true,uncheckedRememberFalse:true},null,2));
console.log('PASS actual remember90 checkbox/boolean/cookie/persistent reopen + session login + hidden requisites/config unchanged');
}finally{await context.close();}
})().catch(error=>{console.error(error);process.exit(1);});
+3
View File
@@ -0,0 +1,3 @@
/* preland#25: protection surface now uses only the inline matrix, unknown fields readonly. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const id=process.env.TARGET_ID||'a',read=()=>page.request.get('/api/config?target='+id).then(r=>r.json()),before=await read();await page.getByLabel('Основной сайт '+id,{exact:true}).waitFor();assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('[data-matrix-extra^=requisites],[data-matrix-extra^="utm.chb"]').count(),0);if(before.config.utm.custom_visible){await page.locator('[data-matrix-extra="utm.custom_visible"]').waitFor();assert.equal(await page.locator('[data-matrix-extra="utm.custom_visible"] input').count(),0);}const after=await read();assert.deepEqual(after.config,before.config);assert.equal(after.version,before.version);console.log('PASS requisites/chb hidden / unknown fields readonly / unchanged runtime');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
+11
View File
@@ -0,0 +1,11 @@
const choosePreset=require('./preset-ui-helper.cjs');
/* preland#25: real accepted jobs, delayed completed HTTP response across logout/login. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root='/fixture',original=new Map(['a','b','c','d'].map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),context=await browser.newContext({baseURL:'http://host.docker.internal:18093'}),page=await context.newPage();page.on('dialog',d=>d.accept());page.setDefaultTimeout(15000);const errors=[];page.on('pageerror',e=>errors.push(e.message));
const login=async()=>{await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await page.getByLabel('Основной сайт '+id,{exact:true}).waitFor();};
const logout=async()=>{await page.getByRole('button',{name:'Выйти',exact:true}).click();await page.locator('#login-panel').waitFor({state:'visible'});};
const host=id=>page.getByLabel('Основной сайт '+id,{exact:true});
const holdJob=async()=>{let release,ready,held=false;const gate=new Promise(r=>release=r),captured=new Promise(r=>ready=r);const handler=async route=>{if(held)return route.continue();held=true;const url=route.request().url();let body;for(let n=0;n<160;n++){body=await page.request.get(url).then(r=>r.json());assert.ok(body.ok,body.error);if(body.job.status==='completed')break;await new Promise(r=>setTimeout(r,100));}assert.equal(body.job.status,'completed');ready(body.job);await gate;await route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)});};await page.route('**/api/jobs?id=*',handler);return {captured,release,stop:()=>page.unroute('**/api/jobs?id=*',handler)};};
try{await page.goto('/');await login();let held=await holdJob();const weight=page.locator('[data-matrix-target=a][data-marker=theme] input[type=number]').first();await weight.fill('40');await page.locator('[data-matrix-target=a][data-marker=theme] input[type=number]').nth(1).fill('60');await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await held.captured;await logout();await login();await host('a').fill('new-session-draft.invalid');const node=await host('a').elementHandle(),notice=await page.locator('#notice').innerText();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});held.release();await page.waitForTimeout(400);assert.equal(await host('a').inputValue(),'new-session-draft.invalid','old Save completion cannot reset a new session draft');assert.deepEqual(await node.evaluate(n=>({connected:n.isConnected,focus:document.activeElement===n,start:n.selectionStart,end:n.selectionEnd})),{connected:true,focus:true,start:2,end:5});assert.equal(await page.locator('#notice').innerText(),notice);await held.stop();await page.getByRole('button',{name:'Отменить настройки a',exact:true}).click();console.log('PASS old accepted Save completion ignored after logout/login');
held=await holdJob();await choosePreset(page,'a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await held.captured;await logout();await login();await host('b').fill('new-session-recipient-draft.invalid');await choosePreset(page,'c');await page.getByLabel('Копировать в d.invalid',{exact:true}).check();assert.equal(await page.locator('#copy-submit').isEnabled(),true,'new login is not blocked by old copyBusy');const current=await holdJob();await page.locator('#copy-submit').click();await current.captured;const currentNotice=await page.locator('#notice').innerText();held.release();await page.waitForTimeout(400);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');assert.equal(await page.locator('#copy-source').isDisabled(),true,'old finally cannot unlock new copy');assert.equal(await page.locator('#copy-results').innerText(),'','old outcomes cannot replace pending new operation');assert.equal(await page.locator('#notice').innerText(),currentNotice);current.release();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled);assert.equal(await page.locator('#copy-results .status-success').count(),1);assert.match(await page.locator('#copy-results').innerText(),/d.invalid/);assert.equal(await host('b').inputValue(),'new-session-recipient-draft.invalid');await current.stop();await held.stop();assert.deepEqual(errors,[]);console.log('PASS old accepted Copy completion/finally ignored; new copyBusy/outcomes/notice/draft preserved');
}finally{await browser.close();for(const [id,text]of original)fs.writeFileSync(root+'/'+id+'/config.json',text);}})().catch(e=>{console.error(e);process.exit(1)});
+5
View File
@@ -0,0 +1,5 @@
/* preland#30: upload a new physical set, manually add its UTM, replace through row control. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs');
(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',raw=fs.readFileSync(root+'/a/config.json','utf8'),stateFiles=['sets.json','checks.json'],snap=new Map(stateFiles.map(name=>[name,fs.existsSync(root+'/state/'+name)?fs.readFileSync(root+'/state/'+name):null]));assert.ok(!fs.existsSync(root+'/a/9'),'isolated fixture must have no set9');const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[],dialogs=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>{dialogs.push(d.message());d.accept();});page.setDefaultTimeout(15000);const config=()=>JSON.parse(fs.readFileSync(root+'/a/config.json','utf8')),done=async()=>{await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();};try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Новый сет 9');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add').screenshot({path:out+'/set-new-form.png'});await page.locator('#set-add button').click();await done();assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),raw,'new ZIP does not change config');assert.equal(fs.readdirSync(root+'/a/9').length,90);const row=page.locator('#sets-body tr').filter({has:page.getByLabel('Название Set 9',{exact:true})});await row.waitFor();assert.equal(await row.getByLabel('Название Set 9',{exact:true}).inputValue(),'Новый сет 9');assert.equal(dialogs.length,0,'new set upload does not ask replacement');
const cell=page.locator('[data-matrix-target=a][data-marker=indpic]');assert.equal(await cell.getByLabel('indpic вес 9 a',{exact:true}).count(),0);await cell.getByRole('button',{name:'Добавить вариант indpic a',exact:true}).click();await cell.screenshot({path:out+'/variant-empty.png'});await cell.getByLabel('Новый вариант indpic a',{exact:true}).fill('9');await cell.getByLabel('indpic вес 9 a',{exact:true}).fill('0');const refreshedSets=page.waitForResponse(r=>r.url().endsWith('/api/sets?target=a'));await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await page.waitForFunction(()=>document.querySelector('[data-overview-target=a] .matrix-status').textContent==='Записано на хостинг',null,{timeout:60000});assert.equal(config().utm.indpic.weights['9'],0);const after=fs.readFileSync(root+'/a/config.json','utf8');await (await refreshedSets).finished();await row.getByLabel('Название Set 9',{exact:true}).waitFor();
await row.getByLabel('ZIP Set 9',{exact:true}).setInputFiles(process.env.VALID_ZIP);await row.getByRole('button',{name:'Загрузить ZIP',exact:true}).click();await done();assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),after,'replace ZIP does not change config');assert.equal(dialogs.length,1);assert.match(dialogs[0],/Заменить существующий набор 9/);assert.equal(fs.readdirSync(root+'/a/9').length,90);await page.locator('#indpic').screenshot({path:out+'/set-new-replaced.png'});assert.deepEqual(errors,[]);console.log('PASS absent set9 upload90files/no configwrites/no replaceconfirm; manual indpic9 zero Save; existing set9 ZIP replace90files/confirm/no configwrites');}finally{await browser.close();fs.writeFileSync(root+'/a/config.json',raw);fs.rmSync(root+'/a/9',{recursive:true,force:true});for(const[name,content]of snap){if(content===null)fs.rmSync(root+'/state/'+name,{force:true});else fs.writeFileSync(root+'/state/'+name,content);}}})().catch(e=>{console.error(e);process.exit(1)});
+3
View File
@@ -0,0 +1,3 @@
/* preland#25: readonly smoke + real local draft/cancel. Writes are covered by ui-matrix.cjs. */
const {chromium}=require('playwright-core'),assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const id=(await page.request.get('/api/state').then(r=>r.json())).targets[0].id,host=page.getByLabel('Основной сайт '+id,{exact:true});await host.waitFor();const original=await host.inputValue();await host.fill('draft-smoke.invalid');assert.match(await page.locator('[data-overview-target="'+id+'"] .matrix-status').innerText(),/Есть изменения/);await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await host.inputValue(),original);assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] .matrix-remove').count(),0);assert.match(await page.locator('.matrix-read-at').first().innerText(),/МСК/);assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS login/nonblank/matrix input-cancel/ZIP table/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)});
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more