feat: единая админка runtime-конфигов и indpic (#22)
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
deploy-preland / deploy zoomstage.online (push) Successful in 1m1s
This commit is contained in:
1 parent
5b6b2d2053
commit
9515c1c62b
52 files changed
+2459
-34
No files matched your search
@@ -20,7 +20,7 @@ jobs:
|
||||
REMOTE_DIR: domains/zoomstage.online
|
||||
steps:
|
||||
- name: Install tools
|
||||
run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli
|
||||
run: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates git curl openssh-client sshpass php-cli php-imagick
|
||||
|
||||
- name: Checkout staging branch
|
||||
env:
|
||||
@@ -38,8 +38,8 @@ jobs:
|
||||
php bin/release-assets.php --check
|
||||
for test in tests/check-*.php; do php "$test"; done
|
||||
php bin/release-assets.php --primary
|
||||
# /N/ по-прежнему принадлежит stage. Любое расхождение требует
|
||||
# сначала перенести актуальный снимок в Git по задаче.
|
||||
# preland#22: /N/ — runtime. Проверяем config и декодируем все 90
|
||||
# обязательных изображений используемых наборов, не сверяем с Git SHA.
|
||||
php bin/release-assets.php --stage-sets "$PRELAND_URL"
|
||||
|
||||
- name: Apply approved domain brand only if needed
|
||||
@@ -85,9 +85,8 @@ jobs:
|
||||
trap 'rm -f "$known_hosts" "$batch" "$files" "$dirs"' EXIT
|
||||
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
|
||||
# Наборы `/N/` на stage — редактируемый контент. Кодовая доставка их
|
||||
# намеренно не перезаписывает: актуальное содержимое stage фиксируют в Git
|
||||
# отдельной синхронизацией по задаче.
|
||||
# Положительный список: tests/bin/планы не публикуются.
|
||||
# намеренно не перезаписывает. Обновление набора через админку не требует Git.
|
||||
# Положительный список: admin/tests/bin/планы/секреты не публикуются.
|
||||
php bin/release-assets.php --files | grep -vE '^(config\.json$|[0-9]+/)' > "$files"
|
||||
if grep -q '[^A-Za-z0-9._/-]' "$files"; then echo "имена с недопустимыми символами:"; grep '[^A-Za-z0-9._/-]' "$files"; exit 1; fi
|
||||
printf '%s\n' "-mkdir $REMOTE_DIR" > "$batch"
|
||||
@@ -141,10 +140,8 @@ jobs:
|
||||
echo "$api"
|
||||
case "$api" in *'zoomzilla.ru\/api\/images\/public\/create'*) ;; *) echo "на странице нет адреса загрузки Zoomzilla"; exit 1 ;; esac
|
||||
printf '%s' "$page" | grep -q 'claim?key=' || { echo "переход не на claim"; exit 1; }
|
||||
curl -fsS --max-time 30 "$PRELAND_URL/1/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован первый набор примеров"; exit 1; }
|
||||
curl -fsS --max-time 30 "$PRELAND_URL/2/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован второй набор примеров"; exit 1; }
|
||||
page2=$(curl -fsS --max-time 30 "$PRELAND_URL/?indpic=abcd2efghi&nc=$(date +%s)")
|
||||
printf '%s' "$page2" | grep -q '2/01_mob_1-source.avif' || { echo "indpic=2 не использует второй набор примеров"; exit 1; }
|
||||
# Список наборов берётся из runtime config, а не фиксируется как /1/ и /2/.
|
||||
# Полная проверка изображений ниже; одиночный HTTP200 недостаточен.
|
||||
php bin/release-assets.php --remote "$PRELAND_URL"
|
||||
php bin/release-brand.php --status "$PRELAND_URL"
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
создавать target-specific конфиги или `config.json.example`.
|
||||
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
|
||||
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
|
||||
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
|
||||
Git отдельной синхронизацией по задаче.
|
||||
актуальные примеры нельзя перезаписывать Git-версией. Снимок можно сохранить в
|
||||
Git по задаче; #22 не требует этого для обычного изменения runtime-набора.
|
||||
- Коммит в `staging` запускает SFTP-автодеплой только на zoomstage.online; откат через `git revert`
|
||||
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
||||
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
||||
@@ -65,7 +65,7 @@
|
||||
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
|
||||
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
|
||||
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
|
||||
- /N/ по-прежнему редактируется на stage и не перезаписывается обычной кодовой доставкой; все150файлов перед выкатом сверяются с Git. Drift блокирует выпуск до импорта актуального набора. При полном переносе репозитория набор уже должен быть правильным.
|
||||
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
|
||||
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
|
||||
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
|
||||
|
||||
@@ -80,3 +80,32 @@
|
||||
primary guard теперь сверяет PDF/favicon, а не заставляет возвращать Zoomzilla и реквизиты на преленд. Реквизиты не отображаются и больше автоматически не синхронизируются; хостовый config и его существующие значения не меняются этим поручением. Устаревшие requisites.html и patch-скрипт удалены из поставки. Статический manifest183, отдельный каталог брендов проверяется по собственным SHA. История #11 сохранена; её прежние инструкции о видимом бренде/реквизитах заменены этим датированным решением.
|
||||
|
||||
Перед выпуском: девять tests/check-*.php и lint, --check/--primary/--stage-sets, оба бренда шапки/подвала на desktop/mobile, перенос Host, отсутствие карт/реквизитов, холодная/медленная загрузка, реальные локальные Preland→Tour→Join/error/retry и геометрия. Production-преленды по #4 не включать; выпускать только staging→zoomstage.online. API продуктовый код не меняется.
|
||||
|
||||
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
|
||||
|
||||
Это решение явно заменяет требование #11 о предварительном импорте изменённых
|
||||
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
|
||||
обновление контента админкой не требует commit/push. Исторические записи `/N/`
|
||||
в `release/assets.json` остаются снимком, не источником истины runtime.
|
||||
|
||||
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
|
||||
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
|
||||
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
|
||||
затем декодируют все 90 обязательных изображений каждого используемого набора,
|
||||
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
|
||||
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
|
||||
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
|
||||
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
|
||||
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
|
||||
|
||||
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
|
||||
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
|
||||
вложенных каталогов и лишних элементов выполнить на фактическом webroot
|
||||
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
|
||||
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
|
||||
|
||||
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
|
||||
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
|
||||
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
|
||||
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
|
||||
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
|
||||
@@ -45,8 +45,8 @@
|
||||
создавать target-specific конфиги или `config.json.example`.
|
||||
- Корневые числовые наборы примеров `/N/` на `zoomstage.online` — редактируемый
|
||||
контент stage. Обычный push в `staging` должен исключать `[0-9]+/` из SFTP-заливки:
|
||||
актуальные примеры нельзя перезаписывать Git-версией. Снимок stage фиксируется в
|
||||
Git отдельной синхронизацией по задаче.
|
||||
актуальные примеры нельзя перезаписывать Git-версией. Снимок можно сохранить в
|
||||
Git по задаче; #22 не требует этого для обычного изменения runtime-набора.
|
||||
- Коммит в `main` запускает FTP-автодеплой на все цели workflow; откат через `git revert`
|
||||
также запускает выкат. Коммит, push и изменения хостинга — только по прямому поручению.
|
||||
- Проверки кода и браузера проводить в Docker без установки ПО на хост.
|
||||
@@ -62,7 +62,7 @@
|
||||
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
|
||||
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
|
||||
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
|
||||
- /N/ по-прежнему редактируется на stage и не перезаписывается обычной кодовой доставкой; все150файлов перед выкатом сверяются с Git. Drift блокирует выпуск до импорта актуального набора. При полном переносе репозитория набор уже должен быть правильным.
|
||||
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
|
||||
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
|
||||
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
|
||||
|
||||
@@ -77,3 +77,32 @@
|
||||
primary guard теперь сверяет PDF/favicon, а не заставляет возвращать Zoomzilla и реквизиты на преленд. Реквизиты не отображаются и больше автоматически не синхронизируются; хостовый config и его существующие значения не меняются этим поручением. Устаревшие requisites.html и patch-скрипт удалены из поставки. Статический manifest183, отдельный каталог брендов проверяется по собственным SHA. История #11 сохранена; её прежние инструкции о видимом бренде/реквизитах заменены этим датированным решением.
|
||||
|
||||
Перед выпуском: девять tests/check-*.php и lint, --check/--primary/--stage-sets, оба бренда шапки/подвала на desktop/mobile, перенос Host, отсутствие карт/реквизитов, холодная/медленная загрузка, реальные локальные Preland→Tour→Join/error/retry и геометрия. Production-преленды по #4 не включать; выпускать только staging→zoomstage.online. API продуктовый код не меняется.
|
||||
|
||||
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
|
||||
|
||||
Это решение явно заменяет требование #11 о предварительном импорте изменённых
|
||||
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
|
||||
обновление контента админкой не требует commit/push. Исторические записи `/N/`
|
||||
в `release/assets.json` остаются снимком, не источником истины runtime.
|
||||
|
||||
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
|
||||
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
|
||||
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
|
||||
затем декодируют все 90 обязательных изображений каждого используемого набора,
|
||||
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
|
||||
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
|
||||
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
|
||||
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
|
||||
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
|
||||
|
||||
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
|
||||
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
|
||||
вложенных каталогов и лишних элементов выполнить на фактическом webroot
|
||||
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
|
||||
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
|
||||
|
||||
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
|
||||
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
|
||||
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
|
||||
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
|
||||
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
|
||||
@@ -144,9 +144,9 @@ N/05_...
|
||||
|
||||
Для `zoomstage.online` обычный deploy ветки `staging` намеренно **не загружает**
|
||||
корневые числовые каталоги `/N/`: это редактируемый контент stage, а не артефакт
|
||||
кодовой доставки. Workflow продолжает проверять их доступность HTTP-smoke. Чтобы
|
||||
зафиксировать обновлённый набор, сначала синхронизируйте его со stage в Git по
|
||||
отдельной задаче; следующая заливка не перетрёт его старой ревизией.
|
||||
кодовой доставки. По #22 workflow проверяет их полный контракт по фактическому
|
||||
config через runtime guard. Импорт изменённого набора в Git не требуется;
|
||||
следующая заливка не перетрёт его старой ревизией.
|
||||
|
||||
## Деплой на произвольный домен
|
||||
|
||||
@@ -219,7 +219,7 @@ Workflow `.gitea/workflows/deploy.yml` содержит единственную
|
||||
- release/assets.json фиксирует SHA-256 всех статических ресурсов (текстовые SVG/CSS/HTML/webmanifest нормализуют CRLF→LF). До выпуска выполнить девять tests/check-*.php, php bin/release-assets.php --check, --primary и --stage-sets https://zoomstage.online. Если источник изменился, сначала синхронизировать актуальные файлы и manifest по задаче, затем повторить проверки. Не отключать guard ради выката.
|
||||
- --primary читает именно текущую страницу основного сайта и проверяет её актуальные URL/реквизиты: старый сохранённый asset URL не доказывает актуальность. Новые логотипы должны пройти визуальную проверку обеих тем/шапки/подвала. Cache version в components/asset-url.php зависит от содержимого.
|
||||
- По прямому выбору владельца21.10 requisites.html теперь входит в каноническую поставку. Единственное новое разрешённое изменение runtime-config — ключ requisites из release/requisites.json через bin/ensure-requisites-config.php; остальные байты полей сохраняются, повторный запуск ничего не меняет. Это явное исключение к прежнему host-only правилу реквизитов, а не разрешение заменять весь config.json. Git config.json хранит согласованный снимок; после выпуска сверять runtime в памяти, не сохранять потенциальные секреты в логи.
|
||||
- /N/ по-прежнему редактируется на stage и не перезаписывается обычной кодовой доставкой; все150файлов перед выкатом сверяются с Git. Drift блокирует выпуск до импорта актуального набора. При полном переносе репозитория набор уже должен быть правильным.
|
||||
- /N/ не перезаписывается кодовой доставкой. Требование #11 о сверке runtime-наборов с Git заменено решением #22 от 25.10.2026 (см. ниже).
|
||||
- SFTP берёт положительный список из bin/release-assets.php --files: никакие tests/bin/release/plans не публикуются. Старые известные служебные файлы удаляются только по точному списку git ls-files, без рекурсивной очистки. После выпуска --remote сверяет все184ресурса.
|
||||
- Production-преленды остаются замороженными по preland#4. Push main ничего не публикует; staging обновляет только zoomstage.online из точного event SHA.
|
||||
|
||||
@@ -238,3 +238,32 @@ primary guard теперь сверяет PDF/favicon, а не заставля
|
||||
Перед выпуском: девять tests/check-*.php и lint, --check/--primary/--stage-sets, оба бренда шапки/подвала на desktop/mobile, перенос Host, отсутствие карт/реквизитов, холодная/медленная загрузка, реальные локальные Preland→Tour→Join/error/retry и геометрия. Production-преленды по #4 не включать; выпускать только staging→zoomstage.online. API продуктовый код не меняется.
|
||||
|
||||
Дополнение приёмки #12: tests/e2e/brand-footer.cjs проверяет именно видимый SVG по центру шапки, а не только headerbox. PUBLISHED=1 использует zoomstage.online (или PRELAND_URL); без него — изолированный pre301.localhost QA proxy. Запускать в имеющемся Docker Playwright с /candidate=репозиторий,/qa=артефакты; POST/PUT блокируются. Пропущенное при compact CSS правило Bootstrap navbar>.container восстановлено во всех3CSS: API уже имеет его из полного Bootstrap. Это устраняет переход logo y0→13.5px, который рамочный тест не замечал.
|
||||
|
||||
## Runtime indpic и отдельная админка (25.10.2026, preland#22)
|
||||
|
||||
Это решение явно заменяет требование #11 о предварительном импорте изменённых
|
||||
наборов `/N/` в Git. Кодовая доставка сохраняет runtime `config.json` и `/N/`;
|
||||
обновление контента админкой не требует commit/push. Исторические записи `/N/`
|
||||
в `release/assets.json` остаются снимком, не источником истины runtime.
|
||||
|
||||
`php bin/release-assets.php --check` сохраняет строгую проверку состава и SHA
|
||||
поставляемой статики, включая запрет старого бренда. `--stage-sets URL` и
|
||||
`--remote URL` читают фактический config и проверяют default, номера/веса indpic,
|
||||
затем декодируют все 90 обязательных изображений каждого используемого набора,
|
||||
проверяют формат и размеры по контракту API `IndpicSetStorage` (591a67cc8302).
|
||||
Проверяются также ссылки с нулевым весом: их разрешает явный UTM и legacy random.
|
||||
Отсутствующий indpic/default использует штатный fallback `/1/`; явно неверный
|
||||
номер/default блокируется. Конфиг перечитывается после проверки для обнаружения
|
||||
его изменения. Одиночный HTTP200 не считается подтверждением валидного набора.
|
||||
|
||||
HTTP не предоставляет inventory каталога: лишние файлы удалённого набора этим
|
||||
режимом не исключаются. Для строгой проверки ровно 90 файлов без симлинков,
|
||||
вложенных каталогов и лишних элементов выполнить на фактическом webroot
|
||||
`php bin/check-runtime-sets.php /path/to/webroot` либо проверку через транспорт
|
||||
админки. Локальная проверка и HTTP требуют PHP Imagick с AVIF/WebP/JPEG.
|
||||
|
||||
`releaseFiles()` — положительный список публичных кодовых/статических ресурсов.
|
||||
`admin/`, его состояние/ключи/резервы, `bin/`, тесты и планы не публикуются обычной
|
||||
доставкой прелендинга. Админка имеет отдельный образ и процедуру выпуска.
|
||||
Проверки перед выпуском: все `tests/check-*.php`, manifest `--check`, `--primary`,
|
||||
`--stage-sets`; после — `--remote` и проверка бренда. Не ослаблять guard ради выпуска.
|
||||
@@ -0,0 +1,6 @@
|
||||
tests
|
||||
vendor
|
||||
state
|
||||
secret.env
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,5 @@
|
||||
/vendor/
|
||||
/state/
|
||||
/secret.env
|
||||
/.env
|
||||
/*.log
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM php:8.3-apache-bookworm@sha256:cbd3b3d9011668443d54ab40ec31db56c73d91b4c8198935d639aa80d2d815c1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libmagickwand-dev libzip-dev unzip ca-certificates git \
|
||||
&& docker-php-ext-install zip \
|
||||
&& printf '\n' | pecl install imagick-3.8.0 \
|
||||
&& docker-php-ext-enable imagick \
|
||||
&& a2enmod rewrite headers \
|
||||
&& groupmod -g 987 www-data && usermod -u 997 -g 987 www-data \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2@sha256:b6dcd72ca98fa19d0e5b8476de179859704891fad06dd9e0b96833d4c430a5fc /usr/bin/composer /usr/local/bin/composer
|
||||
RUN docker-php-ext-install pcntl
|
||||
WORKDIR /app
|
||||
COPY composer.* ./
|
||||
RUN COMPOSER_IPRESOLVE=4 composer install --no-dev --no-interaction --prefer-dist --no-progress --optimize-autoloader
|
||||
COPY . /app
|
||||
COPY deploy/apache.conf /etc/apache2/sites-available/000-default.conf
|
||||
COPY deploy/php.ini /usr/local/etc/php/conf.d/preland-admin.ini
|
||||
RUN mkdir -p /var/lib/preland-admin && chown 997:987 /var/lib/preland-admin
|
||||
ENV ADMIN_STATE_DIR=/var/lib/preland-admin
|
||||
EXPOSE 80
|
||||
CMD ["apache2-foreground"]
|
||||
@@ -0,0 +1,98 @@
|
||||
# Админка прелендингов — preland#22
|
||||
|
||||
Отдельное PHP-приложение для `admin.zoomstage.online`. Формы редактируют фактические
|
||||
`config.json`, выбранные поля применяются к одному или нескольким доменам. ZIP indpic
|
||||
проверяется по контракту CPanel и доставляется на выбранные хостинги. Изменение контента
|
||||
не требует Git/push/deploy; `admin/` не входит в публичную поставку прелендингов.
|
||||
|
||||
Спецификация и план — `docs/superpowers/{specs,plans}/2026-10-25-preland22-*` в репозитории,
|
||||
копии и фактический статус — в базе знаний, основной реестр — preland#22.
|
||||
|
||||
## Состав и доступы
|
||||
|
||||
`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-стенде.
|
||||
|
||||
UID/GID приложения — 997:987. Приложение получает приватный state и ровно два local
|
||||
mount: `/targets/zoompixel.ru`, `/targets/zoomstage.online`. Весь `/srv/preland`, Docker
|
||||
socket, root SSH key и база основного сайта в приложение не передаются. Права публикации
|
||||
на каждом корне проверяются реальным временным файлом/rename при проверке подключения;
|
||||
нельзя включать запись до успешной проверки.
|
||||
|
||||
Два REG.RU-хостинга подключаются SFTP/SSH с их обычными ограниченными учётными записями.
|
||||
Нужны абсолютный корень, host key либо SHA256 fingerprint, пароль или приватный ключ,
|
||||
доступный через stdin CLI `bin/target-import.php`. Секрет хранится 0600 в приватном state,
|
||||
по API не возвращается. На удалённом хосте нужен `php` CLI >=8.1 с `fsync`; удалённый
|
||||
Imagick не нужен. Замена реквизитов сбрасывает проверку подключения.
|
||||
|
||||
## Работа через формы
|
||||
|
||||
1. Выбрать исходный домен: данные и SHA-версия читаются с его хостинга.
|
||||
2. Изменить поля. Отмечаются только введённые поля; можно явно выбрать группу/всю форму.
|
||||
3. Отметить домены и нажать «Применить к выбранным». Каждый домен использует собственную
|
||||
прочитанную версию и сохраняет свои остальные значения. После сохранения перечитать домен.
|
||||
4. В журнале проверить отдельные статусы. Успешные цели не повторяются. Конфликт требует
|
||||
перечитать данные; после неизвестного результата повтор сначала сверяет фактический хеш.
|
||||
5. В indpic выбрать ZIP либо проверить уже загруженную SFTP-папку. Новый набор сам не
|
||||
меняет default/веса. Чтобы назначить новый номер indpic default, он должен присутствовать
|
||||
в таблице весов — можно с весом0. Это действующее правило resolver, а не вес трафика.
|
||||
|
||||
Номера — 1–9, ZIP содержит папку номера и ровно90 изображений установленных форматов/
|
||||
размеров; лимит8МиБ на файл,64МиБ распакованного набора. При нулевой сумме весов rnd
|
||||
использует первый ключ weights по порядку config.json; только cons использует default.
|
||||
Имена numeric/legacy UTM адаптируются отдельно для каждой цели, конфликтующие aliases
|
||||
блокируются. Удаление набора из реестра оставляет файлы; используемый номер удалить нельзя.
|
||||
|
||||
## Каталог групп и приватная публикация
|
||||
|
||||
`deploy/export-catalog.php` выполняется внутри существующего API-контейнера минимальным
|
||||
Yii bootstrap, в read-only DB-транзакции. Он экспортирует только номера/названия degr/tp/cons,
|
||||
не выдавая приложению доступ к БД. `deploy/refresh-catalog.sh` запускается host cron раз
|
||||
в минуту от root. Подготовка/проверка/chown происходят в root-only `secrets/`, затем
|
||||
`mv -fT` заменяет фиксированный `state/catalog.json` без следования ссылке назначения.
|
||||
Снимок действует180секунд только для совпадающего основного host. Недоступный/старый
|
||||
каталог даёт явную пометку непроверенного существования групп, а не выдуманный список.
|
||||
|
||||
Резервы и journal локального транспорта хранятся в приватном state. Для exact bind mount
|
||||
папка подготовки `.preland-admin-staging` создаётся внутри корня с0700; публичный веб-сервер
|
||||
другого UID обязан получать403 и через внешний origin, и через прямой loopback Apache.
|
||||
При наличии nginx-правила deny оно служит дополнительной защитой. Одной надежды на
|
||||
`.htaccess` недостаточно: AllowOverride может быть выключен. Каталог активного `/N/` —0755,
|
||||
файлы0644. SFTP использует приватную sibling-папку вне webroot.
|
||||
|
||||
Переключение каталогов имеет два rename и durable journal. При аварии upload-child
|
||||
supervisor сразу пытается восстановить полный прежний/новый набор под тем же lock;
|
||||
при полном перезапуске это делает следующая операция транспорта. Неподтверждённый
|
||||
результат остаётся unknown. Это восстановление после прерывания, а не обещание отсутствия
|
||||
краткого окна между двумя rename. При недоступном SSH восстановление нельзя подтвердить.
|
||||
|
||||
## Сборка, проверки и выпуск
|
||||
|
||||
```sh
|
||||
docker build -t preland-admin:<git-sha> -f admin/Dockerfile admin
|
||||
docker run --rm --entrypoint composer -v "$PWD:/work:ro" -w /work/admin preland-admin:<git-sha> test
|
||||
docker run --rm --entrypoint bash -v "$PWD:/work:ro" preland-admin:<git-sha> -c 'sed "s/\r$//" /work/admin/tests/catalog-publish.sh | bash'
|
||||
```
|
||||
|
||||
Дополнительные Docker проверки: `tests/backend-sftp.Dockerfile` — настоящий SSH/SFTP,
|
||||
`tests/backend-worker.php` — цикл с паузой65секунд, `tests/ui-*.cjs` — реальные browser
|
||||
сценарии. Последние выполняются только на disposable целях; они изменяют и восстанавливают
|
||||
настройки. `NODE_PATH=/e2e/node_modules`, `CHROMIUM_PATH` задаёт браузер имеющегося e2e-образа.
|
||||
|
||||
Для production подготовить отдельный TLS/nginx по `deploy/nginx.conf`, импортировать
|
||||
проверенные подключения, сохранить резерв состояния, указать точный image в `.env`, затем
|
||||
`docker compose up -d`. Проверить web/worker health, вход, четыре чтения, безопасный реальный
|
||||
цикл сохранения/перечитывания, ZIP и публичные изображения. Не заменять runtime-конфиги
|
||||
Git-снимком. Для отката приложения вернуть прежний `ADMIN_IMAGE`; state и контент сохраняются.
|
||||
Возврат контента выполняется отдельно через резерв и CAS, не через старый Docker-образ.
|
||||
|
||||
Фактические production-команды, версии, ограничения, свидетельства UI и статус подключения
|
||||
четырёх доменов фиксируются в preland#22 и KB; этот README сам не доказывает завершение выпуска.
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
// Пароль читается только из stdin; не передавать его через аргументы командной строки.
|
||||
$password=rtrim(stream_get_contents(STDIN),"\r\n");if(strlen($password)<12){fwrite(STDERR,"Пароль должен содержать не менее 12 символов.\n");exit(1);}echo password_hash($password,PASSWORD_DEFAULT)."\n";
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__).'/bootstrap.php';
|
||||
// Начальная настройка из закрытого stdin. Секреты никогда не выводятся.
|
||||
try{$input=json_decode(stream_get_contents(STDIN),true,64,JSON_THROW_ON_ERROR);$store=new PrelandAdmin\Store();foreach($input as $item){$secret=$item['secret']??null;unset($item['secret']);$target=$store->saveTarget($item,$secret);$actual=$store->target($target['id']);(new PrelandAdmin\Transport($store,$actual))->test();$store->verifyTarget($target['id'],$actual);}echo "Подключения импортированы и проверены.\n";}catch(Throwable){fwrite(STDERR,"Импорт не выполнен. Проверьте приватные параметры и соединения.\n");exit(1);}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
$jobIndex=array_search('--job',$argv,true);
|
||||
// Supervisor не создаёт Imagick: его policy time не должна накапливаться между заданиями.
|
||||
if($jobIndex===false)define('PRELAND_ADMIN_SUPERVISOR',true);
|
||||
require dirname(__DIR__).'/bootstrap.php';
|
||||
$store=new PrelandAdmin\Store();$jobs=new PrelandAdmin\Jobs($store);
|
||||
if($jobIndex!==false){$id=$argv[$jobIndex+1]??'';if(!preg_match('/^[a-f0-9]{32}$/D',$id))exit(2);$jobs->run($id);exit(0);}
|
||||
$once=in_array('--once',$argv,true);
|
||||
do {
|
||||
try {
|
||||
$id=$jobs->pending();
|
||||
if($id!==null){
|
||||
$process=proc_open([PHP_BINARY,__FILE__,'--job',$id],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes);
|
||||
if(!is_resource($process))throw new RuntimeException('Не удалось запустить исполнитель.');fclose($pipes[0]);
|
||||
$deadline=microtime(true)+max(30,min(900,(int)(getenv('ADMIN_JOB_TIMEOUT')?:300)));$exit=null;
|
||||
do{$state=proc_get_status($process);if(!$state['running']){$exit=$state['exitcode'];break;}if(microtime(true)>$deadline){proc_terminate($process);usleep(250000);$state=proc_get_status($process);if($state['running'])proc_terminate($process,9);$exit=124;break;}usleep(100000);}while(true);
|
||||
$closed=proc_close($process);if(($exit??$closed)!==0)$jobs->interrupted($id);
|
||||
}
|
||||
}catch(Throwable){fwrite(STDERR,"Не удалось обработать очередь. Повтор через 2 секунды.\n");}
|
||||
if(!$once)sleep(2);
|
||||
}while(!$once);
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
if (is_file(__DIR__ . '/vendor/autoload.php')) require __DIR__ . '/vendor/autoload.php';
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
$prefix = 'PrelandAdmin\\';
|
||||
if (str_starts_with($class, $prefix)) {
|
||||
$path = __DIR__ . '/src/' . substr($class, strlen($prefix)) . '.php';
|
||||
if (is_file($path)) require $path;
|
||||
}
|
||||
});
|
||||
umask(0077);
|
||||
if (!defined('PRELAND_ADMIN_SUPERVISOR') && class_exists(Imagick::class)) {
|
||||
Imagick::setResourceLimit(Imagick::RESOURCETYPE_MEMORY, 134217728);
|
||||
Imagick::setResourceLimit(Imagick::RESOURCETYPE_MAP, 268435456);
|
||||
Imagick::setResourceLimit(Imagick::RESOURCETYPE_DISK, 536870912);
|
||||
Imagick::setResourceLimit(Imagick::RESOURCETYPE_TIME, 60);
|
||||
Imagick::setResourceLimit(Imagick::RESOURCETYPE_THREAD, 2);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
web:
|
||||
image: ${ADMIN_IMAGE:?Укажите неизменяемую версию образа}
|
||||
restart: unless-stopped
|
||||
env_file: [./secret.env]
|
||||
environment:
|
||||
ADMIN_STATE_DIR: /var/lib/preland-admin
|
||||
ADMIN_LOCAL_ROOTS: /targets/zoompixel.ru,/targets/zoomstage.online
|
||||
volumes:
|
||||
- ./state:/var/lib/preland-admin
|
||||
- /srv/preland/domains/zoompixel.ru:/targets/zoompixel.ru
|
||||
- /srv/preland/domains/zoomstage.online:/targets/zoomstage.online
|
||||
ports: ["127.0.0.1:18084:80"]
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: [ALL]
|
||||
cap_add: [CHOWN, SETGID, SETUID, NET_BIND_SERVICE]
|
||||
healthcheck:
|
||||
test: ["CMD", "php", "-r", "exit(@file_get_contents('http://127.0.0.1/') === false ? 1 : 0);"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
worker:
|
||||
image: ${ADMIN_IMAGE:?Укажите неизменяемую версию образа}
|
||||
restart: unless-stopped
|
||||
user: "997:987"
|
||||
command: ["php", "/app/bin/worker.php"]
|
||||
env_file: [./secret.env]
|
||||
environment:
|
||||
ADMIN_STATE_DIR: /var/lib/preland-admin
|
||||
ADMIN_LOCAL_ROOTS: /targets/zoompixel.ru,/targets/zoomstage.online
|
||||
volumes:
|
||||
- ./state:/var/lib/preland-admin
|
||||
- /srv/preland/domains/zoompixel.ru:/targets/zoompixel.ru
|
||||
- /srv/preland/domains/zoomstage.online:/targets/zoomstage.online
|
||||
security_opt: ["no-new-privileges:true"]
|
||||
cap_drop: [ALL]
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "zoomzilla/preland-admin",
|
||||
"description": "Защищённое управление runtime прелендингов",
|
||||
"license": "proprietary",
|
||||
"require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "phpseclib/phpseclib": "^3.0"},
|
||||
"config": {"sort-packages": true},
|
||||
"scripts": {"test": ["php tests/backend-review.php", "php tests/backend-http.php"], "test:worker": "php tests/backend-worker.php"}
|
||||
}
|
||||
Generated
+253
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"_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": "5f68a3c22e983ea2df00b8c661b975a4",
|
||||
"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": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName admin.zoomstage.online
|
||||
DocumentRoot /app/public
|
||||
<Directory /app/public>
|
||||
AllowOverride None
|
||||
Options -Indexes
|
||||
Require all granted
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</Directory>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set Referrer-Policy "same-origin"
|
||||
Header always set X-Frame-Options "DENY"
|
||||
ErrorLog /proc/self/fd/2
|
||||
# Не логировать параметры запросов, тела форм и секреты.
|
||||
CustomLog /proc/self/fd/1 "%h %t %m %U %>s %b"
|
||||
</VirtualHost>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
/** Read-only экспорт существующих групп; выполняется внутри текущего API-контейнера. */
|
||||
declare(strict_types=1);
|
||||
define('APP_ROOT', getenv('APP_ROOT') ?: getcwd());
|
||||
require APP_ROOT . '/vendor/autoload.php';
|
||||
require APP_ROOT . '/vendor/yiisoft/yii2/Yii.php';
|
||||
$db = Yii::createObject(require APP_ROOT . '/config/database.php');
|
||||
$transaction = $db->beginTransaction(yii\db\Transaction::REPEATABLE_READ);
|
||||
try {
|
||||
$db->createCommand('SET TRANSACTION READ ONLY')->execute();
|
||||
$db->createCommand("SET LOCAL statement_timeout = '5s'")->execute();
|
||||
$groups = [];
|
||||
foreach (['degr' => 'degr_groups', 'tp' => 'tarif_groups', 'cons' => 'consent_groups'] as $marker => $table) {
|
||||
$query = (new yii\db\Query())->select(['number', 'name'])
|
||||
->from('{{%' . $table . '}}')->where(['between', 'number', 1, 9])->orderBy(['number' => SORT_ASC]);
|
||||
if ($marker !== 'degr') {
|
||||
$query->andWhere(['deleted_at' => null]);
|
||||
}
|
||||
$groups[$marker] = array_map(static fn(array $row): array => [
|
||||
'number' => (int) $row['number'], 'name' => (string) $row['name'],
|
||||
], $query->all($db));
|
||||
}
|
||||
$transaction->commit();
|
||||
echo json_encode(['host' => 'zoomzilla.ru', 'generated_at' => gmdate(DATE_ATOM), 'groups' => $groups],
|
||||
JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
|
||||
} catch (Throwable $exception) {
|
||||
if ($transaction->isActive) {
|
||||
$transaction->rollBack();
|
||||
}
|
||||
// Сообщения PDO могут содержать DSN: наружу только безопасный результат.
|
||||
fwrite(STDERR, "Каталог групп не обновлён.\n");
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name admin.zoomstage.online;
|
||||
location ^~ /.well-known/acme-challenge/ { root /var/www/letsencrypt; }
|
||||
location / { return 301 https://$host$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name admin.zoomstage.online;
|
||||
ssl_certificate /etc/letsencrypt/live/admin.zoomstage.online/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/admin.zoomstage.online/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
client_max_body_size 130m;
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:18084;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
expose_php=Off
|
||||
display_errors=Off
|
||||
log_errors=On
|
||||
upload_max_filesize=128M
|
||||
post_max_size=130M
|
||||
max_execution_time=60
|
||||
memory_limit=512M
|
||||
session.cookie_httponly=1
|
||||
session.cookie_samesite=Strict
|
||||
session.use_strict_mode=1
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
base=/srv/preland-admin
|
||||
# Подготовка root-процесса находится вне app-writable mount: UID приложения
|
||||
# не может подменить файл ссылкой между проверкой, chown и chmod.
|
||||
temporary=$(mktemp "$base/secrets/catalog.XXXXXX")
|
||||
trap 'rm -f "$temporary"' EXIT HUP INT TERM
|
||||
if ! /usr/bin/docker exec -i config-api-1 php < "$base/deploy/export-catalog.php" > "$temporary" 2>/dev/null; then
|
||||
echo 'Каталог групп не обновлён; прежний снимок сохранён.' >&2
|
||||
exit 1
|
||||
fi
|
||||
/usr/bin/python3 - "$temporary" <<'PY'
|
||||
import json, sys
|
||||
with open(sys.argv[1], encoding='utf-8') as stream:
|
||||
value = json.load(stream)
|
||||
assert value['host'] == 'zoomzilla.ru'
|
||||
assert set(value['groups']) == {'degr', 'tp', 'cons'}
|
||||
assert all(isinstance(items, list) for items in value['groups'].values())
|
||||
PY
|
||||
chown 997:987 "$temporary"
|
||||
chmod 600 "$temporary"
|
||||
# -T заменяет саму ссылку назначения, не следует в указанный ею каталог.
|
||||
mv -fT "$temporary" "$base/state/catalog.json"
|
||||
@@ -0,0 +1,4 @@
|
||||
/* 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}
|
||||
@@ -0,0 +1,143 @@
|
||||
/* 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 = {csrf:'',targets:[],source:'',reads:new Map(),fields:[],jobs:[],loading:0};
|
||||
const markers=['indpic','theme','degr','reg','banch','tp','cons','focus'];
|
||||
const aliases={reg:'registration',banch:'bchoice'};
|
||||
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':'геометрический центр'}};
|
||||
const formatDate=value=>{const date=new Date(value);return Number.isNaN(date.getTime())?String(value):date.toLocaleString('ru-RU',{timeZone:'Europe/Moscow'})+' МСК';};
|
||||
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;}}));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 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(!response.ok||!body.ok){if(response.status===401)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){notice(error.message);}}
|
||||
function showLogin(){for(const timer of pollTimers.values())clearTimeout(timer);pollTimers.clear();state.csrf='';state.source='';state.fields=[];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 valueAt(object,path){return path.reduce((value,key)=>value&&typeof value==='object'?value[key]:undefined,object);}
|
||||
function equivalent(a,b){return JSON.stringify(a)===JSON.stringify(b);}
|
||||
function readCurrent(){return state.reads.get(state.source);}
|
||||
function chosenChanges(){return state.fields.filter(f=>f.check.checked).map(f=>({path:f.path,value:f.get()}));}
|
||||
function updateSummary(){
|
||||
const fields=state.fields.filter(f=>f.check.checked), ids=selected();
|
||||
$('change-summary').textContent=fields.length?'Выбранные поля: '+fields.map(f=>f.path.join('.')).join(', '):'Поля не выбраны.';
|
||||
$('target-summary').textContent='Цели: '+(ids.map(targetName).join(', ')||'не выбраны');
|
||||
$('apply').disabled=!fields.length||!ids.length||state.loading>0||ids.some(id=>!state.reads.has(id));
|
||||
for(const f of state.fields){const values=ids.map(id=>valueAt(state.reads.get(id)?.config,f.path));f.mixed.textContent=values.length>1&&values.some(v=>!equivalent(v,values[0]))?'Разные значения':'';}
|
||||
}
|
||||
function bindField(container,path,get,controls){
|
||||
const check=el('input',{type:'checkbox','aria-label':'Применить поле '+path.join('.')});
|
||||
const mixed=el('span',{class:'mixed'});const head=el('div',{class:'field-head'},check,el('span',{text:path.join('.')}),mixed);
|
||||
container.prepend(head);const field={path,get,check,mixed};state.fields.push(field);
|
||||
check.addEventListener('change',updateSummary);
|
||||
for(const control of controls)for(const event of ['input','change'])control.addEventListener(event,()=>{check.checked=true;updateSummary();});
|
||||
return field;
|
||||
}
|
||||
function labelControl(text,input){const label=el('label',{text});label.append(input);return label;}
|
||||
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 typed(container,path,value,label){
|
||||
if(value!==null&&typeof value==='object'){
|
||||
const block=el('div',{class:'field-block'},el('h3',{text:label||path.at(-1)}));container.append(block);
|
||||
if(Array.isArray(value)){
|
||||
// Arrays are one explicit atomic field: row operations preserve order and types.
|
||||
const list=el('div',{class:'nested'}),rows=[];block.append(list);let field;
|
||||
const row=(v)=>{const wrap=el('div',{class:'toolbar'});let control,get;
|
||||
if(v!==null&&typeof v==='object'){
|
||||
const subtree=el('div',{class:'nested grow'});const entry={value:structuredClone(v)};
|
||||
const renderObject=(parent,obj)=>{for(const [k,val] of Object.entries(obj)){if(val!==null&&typeof val==='object'){const child=el('div',{class:'nested'},el('strong',{text:k}));parent.append(child);renderObject(child,val);}else{const inp=scalar(val,k);parent.append(labelControl(k,inp));inp.addEventListener('change',()=>{obj[k]=scalarValue(inp,val);field.check.checked=true;updateSummary();});}}};renderObject(subtree,entry.value);control=subtree;get=()=>entry.value;
|
||||
}else{control=scalar(v,label||path.at(-1));get=()=>scalarValue(control,v);}
|
||||
const item={get,wrap};rows.push(item);wrap.append(control,button('Удалить строку',()=>{rows.splice(rows.indexOf(item),1);wrap.remove();field.check.checked=true;updateSummary();}));list.append(wrap);
|
||||
control.addEventListener('input',()=>{field.check.checked=true;updateSummary();});};
|
||||
field=bindField(block,path,()=>rows.map(r=>r.get()),[]);value.forEach(row);
|
||||
block.append(button('Добавить строку',()=>{row(typeof value[0]==='number'?0:typeof value[0]==='boolean'?false:'');field.check.checked=true;updateSummary();}));
|
||||
}else{const nested=el('div',{class:'nested'});block.append(nested);for(const [key,v]of Object.entries(value))typed(nested,[...path,key],v,key);if(!Object.keys(value).length)block.append(el('p',{class:'text-muted',text:'Пустой объект. Поля отсутствуют и не создаются автоматически.'}));}
|
||||
}else{
|
||||
const block=el('div',{class:'field'});const control=scalar(value,label||path.at(-1));block.append(labelControl(label||path.at(-1),control));container.append(block);bindField(block,path,()=>scalarValue(control,value),[control]);
|
||||
}
|
||||
}
|
||||
function scalar(value,label){let input;if(typeof value==='boolean'){input=el('input',{type:'checkbox',checked:value});}else if(typeof value==='number')input=numberInput(value);else if(value===null){input=el('select',{class:'form-control'},el('option',{value:'null',text:'null (пустое значение)'}));}else input=el('input',{class:'form-control',type:'text',value:String(value)});input.setAttribute('aria-label',label);return input;}
|
||||
function scalarValue(input,original){if(typeof original==='boolean')return input.checked;if(typeof original==='number'){if(input.value===''||!Number.isFinite(Number(input.value)))throw new Error('Введите число: '+input.getAttribute('aria-label'));return Number(input.value);}if(original===null)return null;return input.value;}
|
||||
function optionLabel(marker,value){const digit=Object.entries(enums[marker]||{}).find(([,v])=>v===value)?.[0]||value;return digit+(descriptions[marker]?.[digit]?' — '+descriptions[marker][digit]:'');}
|
||||
function utmGroup(container,key,group,marker){
|
||||
const section=el('section',{class:'field-block'},el('h2',{text:marker+'= UTM'}));container.append(section);
|
||||
const groupFieldsStart=state.fields.length;
|
||||
if(!group||typeof group!=='object'||Array.isArray(group)){typed(section,['utm',key],group,key);return;}
|
||||
if(['degr','tp','cons'].includes(marker))section.append(el('p',{class:'alert alert-warning',text:'Существование группы на основном сайте не проверено. Допустим ручной номер 1–9.'}));
|
||||
if(Object.hasOwn(group,'default')){
|
||||
const original=group.default, current=String(original), semantic=!!enums[marker]&&Object.values(enums[marker]).includes(current);
|
||||
const select=el('select',{class:'form-control','aria-label':marker+' default'});const values=enums[marker]?Object.keys(enums[marker]).map(d=>semantic?enums[marker][d]:d):Array.from({length:9},(_,i)=>String(i+1));
|
||||
if(!values.includes(current))values.unshift(current);for(const v of values)select.append(el('option',{value:v,text:optionLabel(marker,v)}));select.value=current;
|
||||
const block=el('div',{class:'field'});block.append(labelControl('Default',select));section.append(block);
|
||||
const field=bindField(block,['utm',key,'default'],()=>typeof original==='number'?Number(select.value):select.value,[select]);
|
||||
if(enums[marker]){const inline=el('div',{class:'utm-inline-options'});for(const v of values){const item=el('div',{class:'utm-inline-option'},el('span',{text:optionLabel(marker,v)}));const choose=el('button',{type:'button',class:'utm-default-action',text:current===v?'default':'назначить',onclick:()=>{select.value=v;field.check.checked=true;for(const b of inline.querySelectorAll('button'))b.textContent=b===choose?'default':'назначить';updateSummary();}});item.append(choose);inline.append(item);}section.append(inline);}
|
||||
}
|
||||
if(Object.hasOwn(group,'weights')&&group.weights&&typeof group.weights==='object'&&!Array.isArray(group.weights)){
|
||||
const block=el('div',{class:'field'}),table=el('table',{class:'table table-bordered weight-table'}),body=el('tbody');table.append(el('thead',{},el('tr',{},el('th',{text:'Вариант'}),el('th',{text:'Вес ≥ 0'}),el('th',{text:'Действия'}))),body);block.append(el('h3',{text:'Веса вариантов'}),el('div',{class:'table-responsive'},table));section.append(block);const rows=[];
|
||||
const field=bindField(block,['utm',key,'weights'],()=>{const weights={};for(const row of rows){const variant=row.variant.value;if(Object.hasOwn(weights,variant))throw new Error(marker+': вариант '+variant+' указан дважды.');if(!Number.isFinite(Number(row.weight.value))||row.weight.value===''||Number(row.weight.value)<0)throw new Error(marker+': вес должен быть неотрицательным числом.');weights[variant]=Number(row.weight.value);}return weights;},[]);
|
||||
const addRow=(variant,weight)=>{const tr=el('tr');const variants=enums[marker]?Object.keys(enums[marker]):Array.from({length:9},(_,i)=>String(i+1));const semantic=!!enums[marker]&&Object.values(enums[marker]).includes(variant);const all=variants.map(d=>semantic?enums[marker][d]:d);if(!all.includes(variant))all.unshift(variant);const v=el('select',{class:'form-control','aria-label':marker+' вариант'},all.map(n=>el('option',{value:n,text:optionLabel(marker,n)})));v.value=variant;const w=numberInput(weight,0);w.setAttribute('aria-label',marker+' вес '+variant);const row={variant:v,weight:w};rows.push(row);tr.append(el('td',{},v),el('td',{},w),el('td',{},button('Удалить',()=>{rows.splice(rows.indexOf(row),1);tr.remove();field.check.checked=true;updateSummary();})));body.append(tr);for(const input of [v,w])input.addEventListener('input',()=>{field.check.checked=true;updateSummary();});};
|
||||
for(const [variant,weight]of Object.entries(group.weights))addRow(variant,weight);
|
||||
block.append(button('Добавить вариант',()=>{addRow('1',0);field.check.checked=true;updateSummary();}),el('p',{class:'text-muted',text:marker==='cons'?'При выборе rnd и нулевой сумме весов используется default.':'При выборе rnd и нулевой сумме весов используется первый вариант weights (в порядке config.json); при пустом списке — default.'}));
|
||||
}
|
||||
for(const [k,v]of Object.entries(group))if(!['default','weights'].includes(k))typed(section,['utm',key,k],v,k);
|
||||
const fields=state.fields.slice(groupFieldsStart);section.prepend(button('Выбрать группу '+marker,()=>{fields.forEach(f=>f.check.checked=true);updateSummary();}));
|
||||
}
|
||||
function renderConfig(){state.fields=[];const container=$('config-fields');container.replaceChildren();const read=readCurrent();if(!read)return;const config=read.config;
|
||||
for(const [key,value]of Object.entries(config))if(key!=='utm')typed(container,[key],value,key==='host'?'Основной сайт':key==='requisites'?'Реквизиты':key);
|
||||
if(config.utm&&typeof config.utm==='object'){
|
||||
for(const marker of markers){const key=Object.hasOwn(config.utm,marker)?marker:aliases[marker]&&Object.hasOwn(config.utm,aliases[marker])?aliases[marker]:marker==='banch'&&Object.hasOwn(config.utm,'chob')?'chob':null;if(key)utmGroup(container,key,config.utm[key],marker);else container.append(el('div',{class:'field-block'},el('h2',{text:marker+'= UTM'}),el('p',{class:'text-muted',text:'Группа отсутствует в этом config.json; автоматически не добавляется.'})));}
|
||||
for(const [key,value]of Object.entries(config.utm))if(!markers.includes(key)&&!Object.values(aliases).includes(key)&&key!=='chob')typed(container,['utm',key],value,key);
|
||||
}
|
||||
$('read-state').textContent='Прочитано '+formatDate(new Date())+' · версия '+read.version.slice(0,12);
|
||||
$('warnings').replaceChildren(...(read.warnings||[]).map(w=>el('p',{class:'alert alert-warning',text:typeof w==='string'?w:JSON.stringify(w)})));
|
||||
updateSummary();
|
||||
}
|
||||
async function fetchRead(id){const data=await api('config?target='+encodeURIComponent(id));state.reads.set(id,data);return data;}
|
||||
async function openTarget(id,force=false){if(!force&&state.fields.some(f=>f.check.checked)&&!confirm('При смене домена несохранённый выбор полей будет сброшен. Продолжить?')){$('source-target').value=state.source;return;}state.source=id;state.fields=[];$('config-fields').replaceChildren();$('sets-body').replaceChildren();$('backups').replaceChildren();$('warnings').replaceChildren();state.reads.clear();renderTargetOptions();updateSummary();if(!id){$('read-state').textContent='Выберите домен.';return;}state.loading++;$('read-state').textContent='Чтение текущей конфигурации…';updateSummary();try{await fetchRead(id);renderConfig();await Promise.all([loadSets(),loadBackups()]);notice('','info');}finally{state.loading--;updateSummary();}}
|
||||
function renderTargetOptions(){const box=$('targets');box.replaceChildren();for(const target of state.targets){const input=el('input',{type:'checkbox',value:target.id,checked:target.id===state.source&&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&&!state.reads.has(target.id)){status.textContent=' · чтение версии…';state.loading++;updateSummary();try{await fetchRead(target.id);status.textContent=' · прочитано';}catch(e){input.checked=false;status.textContent=' · недоступен';throw e;}finally{state.loading--;}}updateSummary();}));}}
|
||||
async function loadState(){const data=await api('state');state.csrf=data.csrf;state.targets=data.targets||[];$('login-panel').hidden=true;$('workspace').hidden=false;$('logout').hidden=false;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();await loadJobs();}
|
||||
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=chosenChanges(),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};});
|
||||
$('apply').disabled=true;try{const result=await api('apply',{targets,changes});state.fields.filter(f=>f.check.checked).forEach(f=>f.check.checked=false);notice('Задание принято. Проверяйте результат каждой цели в журнале.','info');await showJob(result.job);}finally{updateSummary();}
|
||||
}
|
||||
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();if(!state.jobs.length)box.append(el('p',{class:'text-muted',text:'Заданий пока нет.'}));for(const job of state.jobs){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 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',(job.results||[]).map(v=>targetName(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'].includes(job.type);if(configChanged)for(const result of job.results||[])if(result.status==='success')state.reads.delete(result.target);if((job.results||[]).some(r=>r.status==='success'))notice('Задание завершено. Результаты показаны отдельно для каждого домена.'+(configChanged?' Перед следующим изменением перечитайте домен.':''),'info');if(state.source){await loadSets();await loadBackups();}updateSummary();}}
|
||||
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});modal('Проверка соединения',result.message||result.result?.message||'Соединение и пути проверены.');}),button('Удалить',async()=>{if(confirm('Удалить подключение '+t.domain+'? Сайт и файлы останутся.')){await api('targets',{id:t.id},'DELETE');if(state.source===t.id){state.source='';state.fields=[];$('config-fields').replaceChildren();}await loadState();}},'danger')));}}
|
||||
$('login-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const submit=form.querySelector('button');submit.disabled=true;try{const data=await api('login',{username:form.username.value,password:form.password.value});state.csrf=data.csrf||'';form.password.value='';await loadState();notice('','info');}finally{submit.disabled=false;}});});
|
||||
$('logout').addEventListener('click',()=>run(async()=>{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(async()=>{if(!state.source)throw new Error('Выберите домен.');if(state.fields.some(f=>f.check.checked)&&!confirm('Перечитать домен и сбросить несохранённый выбор полей?'))return;await openTarget(state.source,true);}));
|
||||
$('select-all-fields').addEventListener('click',()=>{state.fields.forEach(f=>f.check.checked=true);updateSummary();});$('clear-fields').addEventListener('click',()=>{state.fields.forEach(f=>f.check.checked=false);updateSummary();});
|
||||
$('apply').addEventListener('click',()=>run(()=>applyChanges()));
|
||||
$('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 api('targets',data);form.elements.secret.value='';await loadState();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(state.fields.some(f=>f.check.checked)){event.preventDefault();event.returnValue='';}});
|
||||
run(async()=>{try{await loadState();}catch(error){if(error.status===401)showLogin();else throw error;}});
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__).'/bootstrap.php';
|
||||
use PrelandAdmin\{Auth,Store,Config,Transport,Jobs,Sets,Problem,Catalog};
|
||||
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')reply($auth->login((string)($data['username']??''),(string)($data['password']??''),$_SERVER['REMOTE_ADDR']??'unknown'));
|
||||
$user=$auth->requireUser();
|
||||
if($method!=='GET')$auth->csrf($_SERVER['HTTP_X_CSRF_TOKEN']??'');
|
||||
if($path==='/api/state'&&$method==='GET')reply(['csrf'=>$_SESSION['csrf'],'user'=>$user,'targets'=>$store->targets()]);
|
||||
if($path==='/api/logout'&&$method==='POST'){$auth->logout();reply([]);}
|
||||
if($path==='/api/targets'&&$method==='POST')reply(['target'=>$store->saveTarget((array)($data['target']??[]),isset($data['secret'])?(string)$data['secret']:null)]);
|
||||
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();
|
||||
$catalog=Catalog::read($store,$read['config']);$warnings=[];if($catalog===null)foreach(['degr','tp','cons'] as $key)if(isset($read['config']->utm->$key))$warnings[]="{$key}: существование группы на основном сайте не проверено.";
|
||||
reply(['config'=>$read['config'],'version'=>$read['version'],'warnings'=>$warnings,'catalog'=>$catalog,'sets'=>$sets->list($target,$read['config'])]);
|
||||
}
|
||||
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'=>$jobs->create('apply',$data)],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'=>$jobs->retry((string)($data['id']??''),(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'=>$jobs->create('restore',$data)],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'=>$jobs->create('upload',['targets'=>$targets,'number'=>$number,'name'=>(string)($data['name']??''),'prepared'=>$prepared])],202);
|
||||
}
|
||||
if(in_array($path,['/api/sets/check','/api/sets/register'],true)&&$method==='POST'){$data['number']=Sets::number($data['number']??0);reply(['job'=>$jobs->create($path==='/api/sets/check'?'check':'register',$data)],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){http_response_code($e->status);echo json_encode(['ok'=>false,'error'=>$e->getMessage()],JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR);}
|
||||
catch(JsonException){http_response_code(400);echo json_encode(['ok'=>false,'error'=>'Некорректный JSON запроса.'],JSON_UNESCAPED_UNICODE);}
|
||||
catch(Throwable){http_response_code(500);echo json_encode(['ok'=>false,'error'=>'Операция не выполнена. Проверьте доступность и повторите.'],JSON_UNESCAPED_UNICODE);}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(private Store $store) {}
|
||||
public static function session(): void {
|
||||
if(session_status()===PHP_SESSION_ACTIVE)return;
|
||||
ini_set('session.use_strict_mode','1');ini_set('session.use_only_cookies','1');
|
||||
session_name('preland_admin');
|
||||
session_set_cookie_params(['lifetime'=>0,'path'=>'/','secure'=>getenv('ADMIN_COOKIE_SECURE')!=='0','httponly'=>true,'samesite'=>'Strict']);
|
||||
session_start();
|
||||
}
|
||||
public function login(string $username,string $password,string $ip): 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);
|
||||
});
|
||||
session_regenerate_id(true);$_SESSION=['user'=>$username,'csrf'=>bin2hex(random_bytes(32)),'last'=>time()];return ['user'=>$username,'csrf'=>$_SESSION['csrf']];
|
||||
}
|
||||
public function requireUser(): string {
|
||||
if(empty($_SESSION['user'])||($_SESSION['last']??0)<time()-3600){$_SESSION=[];throw new Problem('Войдите в админку.',401);}$_SESSION['last']=time();return $_SESSION['user'];
|
||||
}
|
||||
public function csrf(string $token): void {if(!$token||!hash_equals($_SESSION['csrf']??'', $token))throw new Problem('Сессия формы истекла. Обновите страницу.',403);}
|
||||
public function logout(): void {$_SESSION=[];session_regenerate_id(true);}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Catalog
|
||||
{
|
||||
public static function read(Store $store,\stdClass $config): ?array {
|
||||
$catalog=$store->read('catalog.json',null);if(!is_array($catalog)||!is_string($catalog['generated_at']??null))return null;
|
||||
$at=strtotime($catalog['generated_at']);if($at===false||$at<time()-180||$at>time()+30)return null;
|
||||
$host=static fn($value)=>strtolower(rtrim((string)parse_url(str_contains((string)$value,'://')?(string)$value:'https://'.$value,PHP_URL_HOST),'.'));
|
||||
if(!$host($config->host??'')||$host($catalog['host']??'')!==$host($config->host??''))return null;
|
||||
foreach(['degr','tp','cons'] as $group){if(!is_array($catalog['groups'][$group]??null))return null;foreach($catalog['groups'][$group] as $entry)if(!is_array($entry)||!is_int($entry['number']??null)||$entry['number']<1||$entry['number']>9||!is_string($entry['name']??null))return null;}
|
||||
return $catalog;
|
||||
}
|
||||
}
|
||||
@@ -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"; }
|
||||
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);
|
||||
}
|
||||
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']];
|
||||
$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, callable $setExists,?array $catalog=null): array {
|
||||
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']];
|
||||
$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'],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 (isset($maps[$canonical])) { $digit=array_search($value,$maps[$canonical],true); if ($digit!==false) return (string)$digit; if (!isset($maps[$canonical][$value])) throw new Problem("Недопустимый вариант {$canonical}."); }
|
||||
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 ($canonical==='indpic' && !$setExists((int)$digit)) throw new Problem("Набор {$digit} отсутствует или не прошёл проверку.");
|
||||
if($catalog!==null&&in_array($canonical,['degr','tp','cons'],true)&&!in_array((int)$digit,array_column($catalog['groups'][$canonical]??[],'number'),true))throw new Problem("Группа {$canonical} {$digit} отсутствует в доступном каталоге основного сайта.");
|
||||
}
|
||||
if (in_array($canonical,['indpic','degr','tp'],true)&&!isset($seen[$default])) throw new Problem("Добавьте вариант {$default} в таблицу весов {$key} (допустим вес 0).");
|
||||
if ($canonical==='indpic' && !$setExists((int)$default)) throw new Problem("Набор default {$default} отсутствует или не прошёл проверку.");
|
||||
if (in_array($canonical,['degr','tp','cons'],true)) {
|
||||
if($catalog===null)$warnings[]="{$canonical}: существование группы на основном сайте не проверено.";
|
||||
elseif(!in_array((int)$default,array_column($catalog['groups'][$canonical]??[],'number'),true))throw new Problem("Группа default {$canonical} {$default} отсутствует в доступном каталоге основного сайта.");
|
||||
}
|
||||
}
|
||||
return $warnings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace PrelandAdmin;
|
||||
final class Conflict extends Problem { public function __construct() { parent::__construct('Файл изменился. Перечитайте настройки и повторите изменение.',409); } }
|
||||
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace PrelandAdmin;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Файловый контракт Set демонстрационных изображений Index.
|
||||
* Перенесено из API 591a67cc8302 components/indpic/IndpicSetStorage.php (preland#22).
|
||||
*
|
||||
* Компонент не знает о БД: он только проверяет и атомарно заменяет содержимое
|
||||
* каталогов `images/indpic/<номер>`. Это позволяет одинаково применять его к
|
||||
* Set, добавленному через SFTP или ZIP CPanel.
|
||||
*/
|
||||
final class IndpicSetStorage
|
||||
{
|
||||
/** @var array<string, array{width: int, height: int}> */
|
||||
private const DIMENSIONS = [
|
||||
'01_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'01_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'01_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'01_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'01_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'01_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'01_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'01_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
'02_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'02_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'02_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'02_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'02_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'02_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'02_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'02_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
'03_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'03_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'03_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'03_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'03_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'03_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'03_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'03_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
'04_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'04_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'04_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'04_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'04_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'04_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'04_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'04_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
'05_desk_1-source.avif' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_1-source.webp' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_1-source.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_2-result.avif' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_2-result.webp' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_2-result.jpg' => ['width' => 1696, 'height' => 920],
|
||||
'05_desk_3-thumb.avif' => ['width' => 200, 'height' => 200],
|
||||
'05_desk_3-thumb.webp' => ['width' => 200, 'height' => 200],
|
||||
'05_desk_3-thumb.jpg' => ['width' => 200, 'height' => 200],
|
||||
'05_mob_1-source.avif' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_1-source.webp' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_1-source.jpg' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_2-result.avif' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_2-result.webp' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_2-result.jpg' => ['width' => 400, 'height' => 217],
|
||||
'05_mob_3-thumb.avif' => ['width' => 60, 'height' => 60],
|
||||
'05_mob_3-thumb.webp' => ['width' => 60, 'height' => 60],
|
||||
'05_mob_3-thumb.jpg' => ['width' => 60, 'height' => 60],
|
||||
];
|
||||
|
||||
private readonly ?\Closure $imageInspector;
|
||||
|
||||
public function __construct(private readonly string $root, ?callable $imageInspector = null)
|
||||
{
|
||||
$this->imageInspector = $imageInspector === null ? null : \Closure::fromCallable($imageInspector);
|
||||
}
|
||||
|
||||
/** @return array<string, array{width: int, height: int}> */
|
||||
public static function expectedDimensions(): array
|
||||
{
|
||||
return self::DIMENSIONS;
|
||||
}
|
||||
|
||||
public function isComplete(int $set): bool
|
||||
{
|
||||
try {
|
||||
$this->validateDirectory($set);
|
||||
return true;
|
||||
} catch (RuntimeException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws RuntimeException если каталог не соответствует контракту Set. */
|
||||
public function validateDirectory(int $set): void
|
||||
{
|
||||
$directory = $this->directory($set);
|
||||
if (!is_dir($directory)) {
|
||||
throw new RuntimeException("Папка Set {$set} не найдена.");
|
||||
}
|
||||
|
||||
$actual = [];
|
||||
$errors = [];
|
||||
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $item) {
|
||||
if ($item->isLink() || !$item->isFile()) {
|
||||
$errors[] = "Лишний элемент в Set {$set}: {$item->getFilename()}.";
|
||||
continue;
|
||||
}
|
||||
$actual[$item->getFilename()] = $item->getPathname();
|
||||
}
|
||||
|
||||
$expected = self::DIMENSIONS;
|
||||
foreach (array_keys($expected) as $filename) {
|
||||
if (!isset($actual[$filename])) {
|
||||
$errors[] = "Отсутствует обязательный файл {$filename}.";
|
||||
}
|
||||
}
|
||||
foreach (array_keys($actual) as $filename) {
|
||||
if (!isset($expected[$filename])) {
|
||||
$errors[] = "Лишний файл {$filename}.";
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($expected as $filename => $dimensions) {
|
||||
if (!isset($actual[$filename])) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$actualDimensions = $this->dimensions($actual[$filename]);
|
||||
if ($actualDimensions !== $dimensions) {
|
||||
$errors[] = sprintf(
|
||||
'Неверный размер %s: %d×%d, ожидалось %d×%d.',
|
||||
$filename,
|
||||
$actualDimensions['width'],
|
||||
$actualDimensions['height'],
|
||||
$dimensions['width'],
|
||||
$dimensions['height']
|
||||
);
|
||||
}
|
||||
} catch (RuntimeException $exception) {
|
||||
$errors[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(implode("\n", $errors));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Полностью и атомарно заменяет Set распакованным ZIP после проверки всех файлов.
|
||||
*
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function replaceFromZip(int $set, string $zipPath): void
|
||||
{
|
||||
$this->assertSetNumber($set);
|
||||
if (!is_file($zipPath)) {
|
||||
throw new RuntimeException('ZIP-файл не получен.');
|
||||
}
|
||||
if (!class_exists(ZipArchive::class)) {
|
||||
throw new RuntimeException('На сервере не доступна поддержка ZIP.');
|
||||
}
|
||||
|
||||
$stagingParent = $this->root . DIRECTORY_SEPARATOR . '.staging';
|
||||
$staging = $stagingParent . DIRECTORY_SEPARATOR . $set . '-' . bin2hex(random_bytes(8));
|
||||
$stagedSet = $staging . DIRECTORY_SEPARATOR . $set;
|
||||
if (!is_dir($stagingParent) && !mkdir($stagingParent, 0775, true) && !is_dir($stagingParent)) {
|
||||
throw new RuntimeException('Не удалось создать staging-каталог Set.');
|
||||
}
|
||||
if (!mkdir($stagedSet, 0775, true)) {
|
||||
throw new RuntimeException('Не удалось подготовить staging-каталог Set.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->extractZip($zipPath, $set, $stagedSet);
|
||||
$this->validateDirectoryPath($stagedSet, $set);
|
||||
$this->activateStagedSet($set, $stagedSet);
|
||||
} catch (\Throwable $exception) {
|
||||
if (is_dir($staging)) {
|
||||
$this->removeDirectory($staging);
|
||||
}
|
||||
throw $exception instanceof RuntimeException
|
||||
? $exception
|
||||
: new RuntimeException('Не удалось заменить Set ZIP-архивом.', 0, $exception);
|
||||
}
|
||||
|
||||
if (is_dir($staging)) {
|
||||
$this->removeDirectory($staging);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractZip(string $zipPath, int $set, string $target): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
throw new RuntimeException('Не удалось открыть ZIP-архив.');
|
||||
}
|
||||
|
||||
try {
|
||||
$prefix = $set . '/';
|
||||
$files = [];
|
||||
$directories = [];
|
||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||
$name = (string) $zip->getNameIndex($index);
|
||||
if ($name === '' || str_contains($name, '\\') || str_starts_with($name, '/') || str_contains($name, '../')) {
|
||||
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
|
||||
}
|
||||
if ($name === $prefix) {
|
||||
$directories[] = $name;
|
||||
continue;
|
||||
}
|
||||
if (!str_starts_with($name, $prefix) || str_contains(substr($name, strlen($prefix)), '/')) {
|
||||
throw new RuntimeException('ZIP содержит недопустимую структуру файлов.');
|
||||
}
|
||||
|
||||
$filename = substr($name, strlen($prefix));
|
||||
if (!isset(self::DIMENSIONS[$filename]) || isset($files[$filename])) {
|
||||
throw new RuntimeException('ZIP содержит лишний или повторяющийся файл.');
|
||||
}
|
||||
$files[$filename] = $index;
|
||||
}
|
||||
|
||||
// Некоторые ZIP-клиенты (в частности проводник Windows) не добавляют
|
||||
// отдельную запись корневого каталога: достаточно 90 файлов с единым
|
||||
// префиксом "<номер Set>/". Вложенные каталоги всё равно отклоняются выше.
|
||||
if (count($directories) > 1 || count($files) !== count(self::DIMENSIONS)) {
|
||||
throw new RuntimeException("ZIP должен содержать папку {$set} и ровно 90 файлов в ней.");
|
||||
}
|
||||
|
||||
foreach (array_keys(self::DIMENSIONS) as $filename) {
|
||||
if (!isset($files[$filename])) {
|
||||
throw new RuntimeException("В ZIP отсутствует обязательный файл {$filename}.");
|
||||
}
|
||||
$stream = $zip->getStream($prefix . $filename);
|
||||
if ($stream === false) {
|
||||
throw new RuntimeException("Не удалось прочитать файл {$filename} из ZIP.");
|
||||
}
|
||||
$destination = $target . DIRECTORY_SEPARATOR . $filename;
|
||||
$output = fopen($destination, 'wb');
|
||||
if ($output === false) {
|
||||
fclose($stream);
|
||||
throw new RuntimeException("Не удалось подготовить файл {$filename}.");
|
||||
}
|
||||
$copied = stream_copy_to_stream($stream, $output, 8388609);
|
||||
fclose($output);
|
||||
fclose($stream);
|
||||
if ($copied === false || $copied > 8388608) {
|
||||
throw new RuntimeException("Не удалось распаковать файл {$filename}.");
|
||||
}
|
||||
chmod($destination, 0644);
|
||||
}
|
||||
} finally {
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
private function activateStagedSet(int $set, string $stagedSet): void
|
||||
{
|
||||
$destination = $this->directory($set);
|
||||
$backup = $this->root . DIRECTORY_SEPARATOR . '.backup-' . $set . '-' . bin2hex(random_bytes(8));
|
||||
$hadDestination = is_dir($destination);
|
||||
|
||||
if ($hadDestination && !rename($destination, $backup)) {
|
||||
throw new RuntimeException('Не удалось подготовить замену текущего Set.');
|
||||
}
|
||||
if (!rename($stagedSet, $destination)) {
|
||||
if ($hadDestination) {
|
||||
rename($backup, $destination);
|
||||
}
|
||||
throw new RuntimeException('Не удалось активировать новый Set.');
|
||||
}
|
||||
if ($hadDestination) {
|
||||
$this->removeDirectory($backup);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{width: int, height: int} */
|
||||
private function dimensions(string $path): array
|
||||
{
|
||||
if ($this->imageInspector !== null) {
|
||||
$dimensions = ($this->imageInspector)($path);
|
||||
if (!is_array($dimensions) || !isset($dimensions['width'], $dimensions['height'])) {
|
||||
throw new RuntimeException('Проверка изображения вернула некорректный размер.');
|
||||
}
|
||||
return ['width' => (int) $dimensions['width'], 'height' => (int) $dimensions['height']];
|
||||
}
|
||||
if (!class_exists(\Imagick::class)) {
|
||||
throw new RuntimeException('На сервере не доступна проверка изображений Imagick.');
|
||||
}
|
||||
|
||||
try {
|
||||
$image = new \Imagick();
|
||||
$image->readImage($path);
|
||||
$format = strtoupper($image->getImageFormat());
|
||||
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
$allowed = ['jpg'=>['JPEG','JPG'], 'webp'=>['WEBP'], 'avif'=>['AVIF','HEIC']];
|
||||
if (!in_array($format, $allowed[$extension] ?? [], true) || $image->getNumberImages() !== 1) {
|
||||
throw new RuntimeException('Формат изображения не совпадает с расширением либо содержит несколько кадров.');
|
||||
}
|
||||
$dimensions = ['width' => $image->getImageWidth(), 'height' => $image->getImageHeight()];
|
||||
$image->clear();
|
||||
$image->destroy();
|
||||
return $dimensions;
|
||||
} catch (\Throwable $exception) {
|
||||
throw new RuntimeException('Файл ' . basename($path) . ' не является корректным изображением.', 0, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateDirectoryPath(string $directory, int $set): void
|
||||
{
|
||||
$storage = new self(dirname($directory), $this->imageInspector);
|
||||
$storage->validateDirectory($set);
|
||||
}
|
||||
|
||||
private function directory(int $set): string
|
||||
{
|
||||
$this->assertSetNumber($set);
|
||||
return $this->root . DIRECTORY_SEPARATOR . $set;
|
||||
}
|
||||
|
||||
private function assertSetNumber(int $set): void
|
||||
{
|
||||
if ($set < 1 || $set > 9) {
|
||||
throw new RuntimeException('Номер Set должен быть от 1 до 9.');
|
||||
}
|
||||
}
|
||||
|
||||
private function removeDirectory(string $directory): void
|
||||
{
|
||||
$iterator = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($iterator as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?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','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','restore'],true)){
|
||||
$current=$transport->readConfig();$expected=$p['version']??'';
|
||||
if($type==='apply')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);
|
||||
$next=$type==='restore'?Config::decode($this->store->backupContent($target['id'],$p['id'])):Config::patch($current['config'],$p['changes']??[]);
|
||||
$checked=[];$warnings=Config::validate($next,function($number)use($target,&$checked){if(array_key_exists($number,$checked))return $checked[$number];try{(new Sets($this->store))->check($target,$number);return $checked[$number]=true;}catch(\Throwable){return $checked[$number]=false;}},Catalog::read($this->store,$next));
|
||||
$content=Config::encode($next);$job['results'][$index]['desired_hash']=hash('sha256',$content);$save();
|
||||
$written=$transport->writeConfig($expected,$content);$out=['message'=>'Настройки сохранены и проверены.','version'=>$written['version'],'warnings'=>$warnings];
|
||||
}else{
|
||||
$sets=new Sets($this->store);$number=Sets::number($p['number']??0);
|
||||
if($type==='upload') {
|
||||
$actual=$sets->manifest($target,$number);$saved=$job['results'][$index];
|
||||
if(array_key_exists('desired_manifest',$saved)){
|
||||
if($actual===$saved['desired_manifest']){
|
||||
$out=$sets->check($target,$number,true,$p['name']??'');
|
||||
$out['message']='Опубликованный набор подтверждён после повторного чтения.';
|
||||
}elseif($actual!==($saved['source_manifest']??null))throw new Conflict();
|
||||
else $out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$saved['source_manifest']??null,true);
|
||||
}else{
|
||||
$job['results'][$index]['source_manifest']=$actual;$job['results'][$index]['desired_manifest']=Sets::preparedManifest($p['prepared'],$number);$save();
|
||||
$out=$sets->upload($target,$number,$p['prepared'],$p['name']??'',$actual,true);
|
||||
}
|
||||
}
|
||||
else $out=$sets->check($target,$number,$type==='register',$p['name']??'');
|
||||
}
|
||||
$job['results'][$index]=['target'=>$target['id'],'status'=>'success']+$out;
|
||||
}catch(Conflict $e){$job['results'][$index]=['target'=>$result['target'],'status'=>'conflict','message'=>$e->getMessage()];}
|
||||
catch(\Throwable $e){$old=$job['results'][$index];$status=$e instanceof Problem&&$e->status===504?'unknown':'error';$job['results'][$index]=['target'=>$result['target'],'status'=>$status,'message'=>$e instanceof Problem?$e->getMessage():'Операция не выполнена. Проверьте доступность и повторите.'];foreach(['desired_hash','desired_manifest','source_manifest'] as $field)if(array_key_exists($field,$old))$job['results'][$index][$field]=$old[$field];}
|
||||
$this->store->audit(['job'=>$id,'target'=>$result['target'],'type'=>$job['type'],'paths'=>array_column($job['payload']['changes']??[],'path'),'status'=>$job['results'][$index]['status']]);$save();
|
||||
}
|
||||
$job['status']='completed';$job['finished_at']=gmdate(DATE_ATOM);$save();
|
||||
});
|
||||
}
|
||||
public function tick(): void {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))$this->run($job['id']);}}
|
||||
public function pending(): ?string {foreach(glob($this->store->dir.'/jobs/*.json')?:[] as $path){$job=$this->store->read('jobs/'.basename($path));if(in_array($job['status'],['queued','running'],true))return $job['id'];}return null;}
|
||||
public function interrupted(string $id): void {
|
||||
$this->store->locked('job-'.$id,function()use($id){$job=$this->store->read('jobs/'.$id.'.json',null);if(!$job||$job['status']==='completed')return;$queued=false;
|
||||
foreach($job['results'] as &$result){
|
||||
if($result['status']==='running'){
|
||||
$message='Исполнитель прервался. Повтор сначала перечитает фактическое состояние цели.';
|
||||
if($job['type']==='upload'){
|
||||
// Read выполняет journal recovery под транспортной блокировкой без Imagick.
|
||||
// Supervisor обязан попытаться вернуть публичный путь до ожидания retry пользователя.
|
||||
try{(new Transport($this->store,$this->store->target($result['target'])))->readConfig();$result['recovery_checked']=true;}
|
||||
catch(\Throwable){$result['recovery_checked']=false;$message='Исполнитель прервался. Автоматическое восстановление не подтверждено: цель недоступна. Повтор сначала перечитает фактическое состояние.';}
|
||||
}
|
||||
$result['status']='unknown';$result['message']=$message;
|
||||
}
|
||||
if($result['status']==='queued')$queued=true;
|
||||
}unset($result);
|
||||
$job['status']=$queued?'queued':'completed';if(!$queued)$job['finished_at']=gmdate(DATE_ATOM);$this->store->write('jobs/'.$id.'.json',$job);
|
||||
$this->store->audit(['job'=>$id,'status'=>'interrupted']);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
namespace PrelandAdmin;
|
||||
class Problem extends \RuntimeException { public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); } }
|
||||
@@ -0,0 +1,111 @@
|
||||
<?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 {
|
||||
$root=$p['root'];
|
||||
if (!is_string($root)||!str_starts_with($root,'/')||str_contains($root,'..')||str_contains($root,'\\')||realpath($root)!==$root||is_link($root)) return ['ok'=>false,'error'=>'Небезопасный корневой путь.'];
|
||||
$private=$p['local_private']??dirname($root).'/.preland-admin-'.substr(hash('sha256',$root),0,16);
|
||||
if (is_link($private)) return ['ok'=>false,'error'=>'Небезопасный приватный каталог.'];
|
||||
if (!is_dir($private) && !mkdir($private,0700)) return ['ok'=>false,'error'=>'Не удалось подготовить приватный каталог вне webroot.'];
|
||||
chmod($private,0700);
|
||||
$lock=fopen($private.'/publish.lock','c');if(!$lock) return ['ok'=>false,'error'=>'Не удалось получить удалённую блокировку.'];
|
||||
$deadline=microtime(true)+20;while(!flock($lock,LOCK_EX|LOCK_NB)){if(microtime(true)>$deadline){fclose($lock);return ['ok'=>false,'error'=>'Истекло время ожидания блокировки.'];}usleep(50000);}
|
||||
try {
|
||||
self::recover($root,$private,isset($p['local_private']));
|
||||
$config=$root.'/config.json';
|
||||
if (is_link($config)||!is_file($config)||filesize($config)>2097152) throw new \RuntimeException('Настройки отсутствуют либо путь небезопасен.');
|
||||
$content=file_get_contents($config);$version=hash('sha256',$content);
|
||||
$op=$p['op'];
|
||||
if ($op==='read') return ['ok'=>true,'content'=>$content,'version'=>$version];
|
||||
if ($op==='test') {
|
||||
if(!function_exists('fsync'))throw new \RuntimeException('Для безопасной публикации требуется PHP CLI не ниже 8.1 с fsync.');
|
||||
if(!is_writable($root)||!is_writable($config)) throw new \RuntimeException('Нет прав записи.');
|
||||
if(!json_decode($content) instanceof \stdClass)throw new \RuntimeException('config.json не является корректным объектом.');
|
||||
$probe=$root.'/.config-probe-'.bin2hex(random_bytes(8));$renamed=$probe.'-renamed';
|
||||
try{if(file_put_contents($probe,'preland-admin-probe')===false||!rename($probe,$renamed)||file_get_contents($renamed)!=='preland-admin-probe')throw new \RuntimeException('Атомарная запись недоступна.');}finally{if(is_file($probe))unlink($probe);if(is_file($renamed))unlink($renamed);}
|
||||
return ['ok'=>true,'version'=>$version];
|
||||
}
|
||||
if ($op==='set-list') {$numbers=[];foreach(range(1,9) as $n)if(is_dir($root.'/'.$n)&&!is_link($root.'/'.$n))$numbers[]=$n;return ['ok'=>true,'numbers'=>$numbers];}
|
||||
if ($op==='write') {
|
||||
if (!hash_equals($version,(string)$p['version'])) return ['ok'=>false,'conflict'=>true,'error'=>'Версия изменена.'];
|
||||
$next=$p['content'];$decoded=json_decode($next);if(!$decoded instanceof \stdClass || strlen($next)>2097152) throw new \RuntimeException('Некорректные настройки.');
|
||||
$temp=$root.'/.config-'.bin2hex(random_bytes(8));$backup=$private.'/config-'.gmdate('YmdHis').'-'.bin2hex(random_bytes(6)).'.json';
|
||||
if(file_put_contents($backup,$content)===false) throw new \RuntimeException('Не удалось сохранить резерв.');chmod($backup,0600);
|
||||
if(file_put_contents($temp,$next)===false) throw new \RuntimeException('Не удалось записать временный файл.');chmod($temp,0644);
|
||||
if(hash_file('sha256',$temp)!==hash('sha256',$next)||!rename($temp,$config)){@unlink($temp);throw new \RuntimeException('Не удалось атомарно заменить настройки.');}
|
||||
$actual=hash_file('sha256',$config);if($actual!==hash('sha256',$next)) throw new \RuntimeException('Повторная проверка настроек не совпала.');
|
||||
return ['ok'=>true,'version'=>$actual,'previous'=>$content];
|
||||
}
|
||||
$number=(int)($p['number']??0);if($number<1||$number>9) throw new \RuntimeException('Номер набора должен быть 1–9.');
|
||||
$dir=$root.'/'.$number;if(is_link($dir)) throw new \RuntimeException('Символические ссылки набора запрещены.');
|
||||
if($op==='set-manifest')return ['ok'=>true,'manifest'=>self::manifest($dir)];
|
||||
if($op==='set-read') {
|
||||
if(!is_dir($dir)) throw new \RuntimeException('Папка набора отсутствует.');$files=[];$bytes=0;
|
||||
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if(!$item->isFile()||$item->isLink()||$item->getSize()>8388608) throw new \RuntimeException('Недопустимый файл набора (лимит 8 МиБ).');$bytes+=$item->getSize();if($bytes>67108864||count($files)>=90) throw new \RuntimeException('Превышен лимит набора (64 МиБ, 90 файлов).');$files[$item->getFilename()]=base64_encode(file_get_contents($item->getPathname()));}
|
||||
return ['ok'=>true,'files'=>$files];
|
||||
}
|
||||
if($op==='set-write') {
|
||||
if(!array_key_exists('expected_manifest',$p))throw new \RuntimeException('Отсутствует исходная версия набора.');
|
||||
if(self::manifest($dir)!==$p['expected_manifest'])return ['ok'=>false,'conflict'=>true,'error'=>'Набор изменился. Перечитайте его состояние перед повторной публикацией.'];
|
||||
$stageParent=isset($p['local_private'])?$root.'/.preland-admin-staging':$private;
|
||||
if(is_link($stageParent))throw new \RuntimeException('Небезопасный staging-каталог.');
|
||||
if(!is_dir($stageParent)&&!mkdir($stageParent,0700))throw new \RuntimeException('Не удалось подготовить staging.');
|
||||
if(isset($p['local_private']))file_put_contents($stageParent.'/.htaccess',"Require all denied\n");
|
||||
$stage=$stageParent.'/set-'.bin2hex(random_bytes(8));if(!mkdir($stage,0755))throw new \RuntimeException('Не удалось подготовить набор.');$backup=$stageParent.'/backup-'.$number.'-'.bin2hex(random_bytes(8));$moved=false;
|
||||
try {
|
||||
if(count($p['files'])!==90) throw new \RuntimeException('Требуется ровно 90 файлов.');$total=0;
|
||||
foreach($p['files'] as $name=>$file){if(!preg_match('/^[0-9]{2}_(desk|mob)_[123]-(source|result|thumb)\.(jpg|webp|avif)$/D',$name))throw new \RuntimeException('Недопустимое имя файла.');$data=base64_decode($file['data'],true);$total+=strlen($data===false?'':$data);if($data===false||strlen($data)>8388608||$total>67108864||hash('sha256',$data)!==$file['sha256'])throw new \RuntimeException('Не совпала проверка файла либо превышен лимит.');$path=$stage.'/'.$name;if(file_put_contents($path,$data)===false||hash_file('sha256',$path)!==$file['sha256'])throw new \RuntimeException('Ошибка доставки файла.');chmod($path,0644);}
|
||||
if(!chmod($stage,0755))throw new \RuntimeException('Не удалось открыть чтение подготовленного набора.');
|
||||
if(is_dir($dir)){
|
||||
// Резерв локального bind mount копируется в state до смены активного каталога.
|
||||
if(isset($p['local_private'])){
|
||||
$saved=$private.'/backup-'.$number.'-'.bin2hex(random_bytes(8));if(!mkdir($saved,0700))throw new \RuntimeException('Не удалось подготовить резерв.');
|
||||
foreach(new \FilesystemIterator($dir,\FilesystemIterator::SKIP_DOTS) as $item){if($item->isLink()||!$item->isFile()||!copy($item->getPathname(),$saved.'/'.$item->getFilename()))throw new \RuntimeException('Не удалось сохранить резерв набора.');}
|
||||
}
|
||||
self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
|
||||
if(!rename($dir,$backup))throw new \RuntimeException('Не удалось сохранить старый набор.');$moved=true;
|
||||
}
|
||||
else self::journal($private.'/swap-'.$number.'.json',['number'=>$number,'stage'=>$stage,'backup'=>$backup,'desired_manifest'=>self::manifest($stage)]);
|
||||
if(!rename($stage,$dir)) {if($moved)rename($backup,$dir);throw new \RuntimeException('Не удалось активировать набор.');}
|
||||
foreach($p['files'] as $name=>$file)if(hash_file('sha256',$dir.'/'.$name)!==$file['sha256']) {rename($dir,$stage);if($moved)rename($backup,$dir);throw new \RuntimeException('Ошибка проверки после публикации; выполнен откат.');}
|
||||
if($moved && isset($p['local_private'])) {
|
||||
foreach(new \FilesystemIterator($backup,\FilesystemIterator::SKIP_DOTS) as $item)unlink($item->getPathname());rmdir($backup);
|
||||
}
|
||||
unlink($private.'/swap-'.$number.'.json');
|
||||
return ['ok'=>true,'hash'=>hash('sha256',json_encode(array_map(static fn($f)=>$f['sha256'],$p['files'])))];
|
||||
} finally {if(is_dir($stage)){foreach(new \FilesystemIterator($stage,\FilesystemIterator::SKIP_DOTS) as $item)if($item->isFile())unlink($item->getPathname());rmdir($stage);}}
|
||||
}
|
||||
throw new \RuntimeException('Неизвестная операция.');
|
||||
} catch (\Throwable $e) {return ['ok'=>false,'error'=>$e->getMessage()];} finally {flock($lock,LOCK_UN);fclose($lock);}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
final class Sets
|
||||
{
|
||||
public function __construct(private Store $store) {}
|
||||
public static function number(mixed $number): int {if(!preg_match('/^[1-9]$/D',(string)$number)) throw new Problem('Номер набора должен быть 1–9.');return (int)$number;}
|
||||
public function list(array $target, ?\stdClass $config=null): array {
|
||||
$names=$this->store->read('sets.json');$checks=$this->store->read('checks.json');$result=[];$available=(new Transport($this->store,$target))->call(['op'=>'set-list'])['numbers'];
|
||||
$config ??= (new Transport($this->store,$target))->readConfig()['config'];$group=$config->utm->indpic ?? new \stdClass();
|
||||
foreach(range(1,9) as $number){$key=$target['id'].':'.$number;$default=(string)($group->default??'')===(string)$number;$weight=(float)($group->weights->{(string)$number}??0);$entry=$names[$key]??null;
|
||||
$result[]=['number'=>$number,'name'=>$entry['name']??'','exists'=>in_array($number,$available,true),'registered'=>$entry!==null,'default'=>$default,'in_use'=>$default||$weight>0,'checked_at'=>$checks[$key]['at']??null];
|
||||
}return $result;
|
||||
}
|
||||
public function prepareZip(string $file,int $number): string {
|
||||
self::number($number);if(!is_file($file)||filesize($file)>67108864)throw new Problem('ZIP превышает лимит 64 МиБ.');
|
||||
$zip=new \ZipArchive();if($zip->open($file)!==true)throw new Problem('Не удалось открыть ZIP.');$total=0;
|
||||
try{if($zip->numFiles>91)throw new Problem('ZIP содержит более 90 файлов.');for($i=0;$i<$zip->numFiles;$i++){$stat=$zip->statIndex($i);$size=(int)$stat['size'];$total+=$size;if($size>8388608||$total>67108864)throw new Problem('Превышен лимит распаковки (8 МиБ на файл, 64 МиБ на набор).');$opsys=0;$attr=0;if($zip->getExternalAttributesIndex($i,$opsys,$attr)&&(($attr>>16)&0170000)===0120000)throw new Problem('Символические ссылки в ZIP запрещены.');}}finally{$zip->close();}
|
||||
$dir=$this->store->dir.'/uploads/'.bin2hex(random_bytes(16));if(!mkdir($dir,0700,true))throw new Problem('Не удалось подготовить проверку ZIP.');
|
||||
(new IndpicSetStorage($dir))->replaceFromZip($number,$file);return $dir;
|
||||
}
|
||||
public function check(array $target,int $number,bool $register=false,string $name=''): array {
|
||||
$number=self::number($number);$transport=new Transport($this->store,$target);$result=$transport->call(['op'=>'set-read','number'=>$number]);
|
||||
$dir=$this->store->dir.'/checks/'.bin2hex(random_bytes(16));mkdir($dir.'/'.$number,0700,true);
|
||||
try {
|
||||
$expected=IndpicSetStorage::expectedDimensions();if(count($result['files'])!==90)throw new Problem('Набор должен содержать ровно 90 файлов.');$hashes=[];
|
||||
foreach($result['files'] as $filename=>$encoded){if(!isset($expected[$filename]))throw new Problem('В наборе найден лишний файл.');$data=base64_decode($encoded,true);if($data===false)throw new Problem('Ошибка чтения изображения.');file_put_contents($dir.'/'.$number.'/'.$filename,$data);$hashes[$filename]=hash('sha256',$data);}
|
||||
(new IndpicSetStorage($dir))->validateDirectory($number);ksort($hashes);$hash=hash('sha256',json_encode($hashes));
|
||||
$this->store->locked('checks',function() use($target,$number,$hash){$checks=$this->store->read('checks.json');$checks[$target['id'].':'.$number]=['at'=>gmdate(DATE_ATOM),'hash'=>$hash];$this->store->write('checks.json',$checks);});
|
||||
if($register)$this->name($target,$number,$name);
|
||||
return ['message'=>'Проверены 90 изображений, размеры и форматы.','hash'=>$hash];
|
||||
}finally{foreach(glob($dir.'/'.$number.'/*')?:[] as $path)unlink($path);@rmdir($dir.'/'.$number);@rmdir($dir);}
|
||||
}
|
||||
public function manifest(array $target,int $number): ?string {return (new Transport($this->store,$target))->call(['op'=>'set-manifest','number'=>self::number($number)])['manifest'];}
|
||||
public static function preparedManifest(string $dir,int $number): string {$hashes=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.self::number($number).'/'.$filename;if(!is_file($path)||is_link($path))throw new Problem('Подготовленный набор повреждён.');$hashes[$filename]=hash_file('sha256',$path);}ksort($hashes);return hash('sha256',json_encode($hashes,JSON_THROW_ON_ERROR));}
|
||||
public function upload(array $target,int $number,string $dir,string $name,?string $expectedManifest=null,bool $hasExpected=false): array {
|
||||
(new IndpicSetStorage($dir))->validateDirectory($number);$files=[];
|
||||
foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename){$path=$dir.'/'.$number.'/'.$filename;$files[$filename]=['data'=>base64_encode(file_get_contents($path)),'sha256'=>hash_file('sha256',$path)];}
|
||||
if(!$hasExpected)$expectedManifest=$this->manifest($target,$number);
|
||||
$result=(new Transport($this->store,$target))->call(['op'=>'set-write','number'=>$number,'files'=>$files,'expected_manifest'=>$expectedManifest]);
|
||||
$checked=$this->check($target,$number,true,$name);return $checked;
|
||||
}
|
||||
public function name(array $target,int $number,string $name): void {
|
||||
self::number($number);if(preg_match('//u',$name)!==1||preg_match_all('/./us',$name)>200||preg_match('/[\x00-\x1f]/',$name))throw new Problem('Название набора слишком длинное либо содержит управляющие символы.');
|
||||
$this->store->locked('sets',function() use($target,$number,$name){$sets=$this->store->read('sets.json');$sets[$target['id'].':'.$number]=['name'=>$name];$this->store->write('sets.json',$sets);});
|
||||
}
|
||||
public function delete(array $target,int $number): void {
|
||||
$config=(new Transport($this->store,$target))->readConfig()['config'];foreach($this->list($target,$config) as $set)if($set['number']===$number&&$set['in_use'])throw new Problem('Нельзя убрать используемый набор. Сначала измените default и веса.');
|
||||
$this->store->locked('sets',function() use($target,$number){$sets=$this->store->read('sets.json');unset($sets[$target['id'].':'.$number]);$this->store->write('sets.json',$sets);});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?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['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'] ?? '';
|
||||
// Изменение транспорта сбрасывает проверку: запись включается только после 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 deleteTarget(string $id): void {$this->locked('targets',function() use($id){$targets=$this->read('targets.json');unset($targets[$id]);$this->write('targets.json',$targets);});}
|
||||
public function backup(string $target,string $content): string {
|
||||
$id=gmdate('YmdHis').'-'.bin2hex(random_bytes(6));$items=$this->read('backups/index.json');$items[]=['id'=>$id,'target'=>$target,'created_at'=>gmdate(DATE_ATOM)];
|
||||
$this->write('backups/'.$id.'.json',['content'=>$content]);$this->locked('backup-index',function() use($items){$current=$this->read('backups/index.json');$current[]=end($items);$this->write('backups/index.json',$current);});return $id;
|
||||
}
|
||||
public function backups(string $target): array {return array_values(array_map(static fn($item)=>array_diff_key($item,['target'=>true]),array_filter($this->read('backups/index.json'),static fn($item)=>$item['target']===$target)));}
|
||||
public function backupContent(string $target,string $id): string {foreach($this->backups($target) as $item) if($item['id']===$id) return $this->read('backups/'.$id.'.json')['content'];throw new Problem('Резервная версия не найдена.',404);}
|
||||
public function audit(array $data): void {$this->locked('audit',function() use($data){file_put_contents($this->dir.'/audit.jsonl',json_encode(['at'=>gmdate(DATE_ATOM)]+$data,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)."\n",FILE_APPEND|LOCK_EX);});}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace PrelandAdmin;
|
||||
use phpseclib3\Net\SSH2;
|
||||
use phpseclib3\Net\SFTP;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
final class Transport
|
||||
{
|
||||
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);
|
||||
// Аргументы проходят shell quoting, обработчик не принимает команды от клиента.
|
||||
$runner=$code."\necho json_encode(\\PrelandAdmin\\Remote::execute(json_decode(base64_decode(\$argv[1]),true,64,JSON_THROW_ON_ERROR)),JSON_THROW_ON_ERROR);";
|
||||
$command='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg(base64_encode(json_encode($payload,JSON_THROW_ON_ERROR)));
|
||||
// Большие наборы доставляются SFTP в приватный inbox: argv/ARG_MAX не используется.
|
||||
if(strlen($command)>60000) {
|
||||
$sftp=$this->sftp();$parent=dirname($this->target['root']);$private=$parent.'/.preland-admin-'.substr(hash('sha256',$this->target['root']),0,16);
|
||||
$this->call(['op'=>'test']);$inbox=$private.'/inbox-'.bin2hex(random_bytes(16));
|
||||
if(!$sftp->put($inbox,json_encode($payload,JSON_THROW_ON_ERROR)))throw new Problem('Не удалось доставить подготовленный набор.');$sftp->chmod(0600,$inbox);
|
||||
$runner=$code."\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='php -d display_errors=0 -r '.escapeshellarg($runner).' '.escapeshellarg($inbox);
|
||||
}
|
||||
$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); }
|
||||
}
|
||||
if(!($result['ok']??false)) {if(!empty($result['conflict']))throw new Conflict();throw new Problem($result['error']??'Ошибка транспорта.');}return $result;
|
||||
}
|
||||
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 {return $this->call(['op'=>'test']);}
|
||||
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 {
|
||||
$before=$this->call(['op'=>'read']);if(!hash_equals($version,$before['version']))throw new Conflict();
|
||||
$this->store->backup($this->target['id'],$before['content']);
|
||||
return $this->call(['op'=>'write','version'=>$version,'content'=>$content]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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';
|
||||
$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]=request('/api/state');check($status===401&&!$body['ok'],'HTTP unauthenticated state blocked');
|
||||
[$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,$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'];
|
||||
[$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/logout','POST',null,$csrf);check($status===200,'HTTP logout succeeds');
|
||||
[$status,$body]=request('/api/state');check($status===401,'HTTP logout invalidates session');
|
||||
echo "HTTP integration complete\n";
|
||||
}finally{proc_terminate($server);proc_close($server);fclose($log);}
|
||||
@@ -0,0 +1,11 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <dlfcn.h>
|
||||
#include <signal.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
int rename(const char *oldpath, const char *newpath) {
|
||||
int (*original)(const char *, const char *) = dlsym(RTLD_NEXT, "rename");
|
||||
int result = original(oldpath, newpath);
|
||||
if (result == 0 && strstr(newpath, "/.preland-admin-staging/backup-1-") != NULL) kill(getpid(), SIGKILL);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
if(($argv[1]??'')==='--crash-child') {require dirname(__DIR__).'/bootstrap.php';$p=json_decode(file_get_contents($argv[2]),true,64,JSON_THROW_ON_ERROR);PrelandAdmin\Remote::execute($p);exit;}
|
||||
require __DIR__.'/backend-security.php';
|
||||
use PrelandAdmin\{Config,Catalog,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":1,"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":1,"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');
|
||||
rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":1,"9":0}}}}'),static fn($n)=>$n===1),'zero-weight indpic variant requires complete physical set');
|
||||
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'),static fn()=>true)===[],'enum default does not invent weights-membership restriction');
|
||||
$catalog=['host'=>'https://ZOOMZILLA.RU:443','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[['number'=>1,'name'=>'one']],'cons'=>[['number'=>1,'name'=>'one']]]];$store->write('catalog.json',$catalog);$cfg=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"1","weights":{"1":1}}}}');$fresh=Catalog::read($store,$cfg);check($fresh!==null&&Config::validate($cfg,static fn()=>true,$fresh)===[],'fresh matching catalog validates actual available number without fallback warning');
|
||||
$invalid=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":1}}}}');rejects(fn()=>Config::validate($invalid,static fn()=>true,$fresh),'fresh catalog rejects unknown number');$catalog['generated_at']=gmdate(DATE_ATOM,time()-181);$store->write('catalog.json',$catalog);check(Catalog::read($store,$cfg)===null&&count(Config::validate($invalid,static fn()=>true,null))===1,'stale catalog falls back to explicit unverified warning');
|
||||
$validPrepared=$sets->prepareZip($zipPath,1);$source=$sets->manifest($target,1);$desired=Sets::preparedManifest($validPrepared,1);
|
||||
$original=file_get_contents($root.'/site/1/01_desk_1-source.jpg');$img=new Imagick();$img->newImage(1696,920,'#aabbcc');$img->setImageFormat('jpg');file_put_contents($root.'/site/1/01_desk_1-source.jpg',$img->getImagesBlob());$img->clear();$foreign=$sets->manifest($target,1);
|
||||
rejects(fn()=>$sets->upload($target,1,$validPrepared,'CAS',$source,true),'set CAS rejects third-party manifest changed after read');check($sets->manifest($target,1)===$foreign,'set CAS preserves third-party images');
|
||||
$unknown=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Retry']);$r=$store->read('jobs/'.$unknown['id'].'.json');$r['status']='completed';$r['results'][0]=['target'=>'test','status'=>'unknown','message'=>'fixture','source_manifest'=>$source,'desired_manifest'=>$desired];$store->write('jobs/'.$unknown['id'].'.json',$r);$jobs->retry($unknown['id'],['test']);$jobs->run($unknown['id']);check($jobs->get($unknown['id'])['results'][0]['status']==='conflict'&&$sets->manifest($target,1)===$foreign,'unknown upload retry detects third-party publication and never overwrites it');
|
||||
$sets->upload($target,1,$validPrepared,'CAS',$foreign,true);$unknown2=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Retry']);$r=$store->read('jobs/'.$unknown2['id'].'.json');$r['status']='completed';$r['results'][0]=['target'=>'test','status'=>'unknown','message'=>'fixture','source_manifest'=>$foreign,'desired_manifest'=>$desired];$store->write('jobs/'.$unknown2['id'].'.json',$r);$before=count(glob($store->dir.'/local-*/backup-1-*')?:[]);$jobs->retry($unknown2['id'],['test']);$jobs->run($unknown2['id']);check($jobs->get($unknown2['id'])['results'][0]['status']==='success'&&count(glob($store->dir.'/local-*/backup-1-*')?:[])===$before,'already-published desired upload reconciles without another swap or backup');
|
||||
// SIGKILL в реальном libc rename, после переноса old→backup, без production test hooks.
|
||||
$library=$root.'/kill-rename.so';$compile=proc_open(['gcc','-shared','-fPIC','-o',$library,__DIR__.'/backend-kill-rename.c','-ldl'],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes);fclose($pipes[0]);check(proc_close($compile)===0,'fault injector builds inside Docker');
|
||||
$files=[];foreach(array_keys(PrelandAdmin\IndpicSetStorage::expectedDimensions()) as $name)$files[$name]=['data'=>base64_encode(file_get_contents($validPrepared.'/1/'.$name)),'sha256'=>hash_file('sha256',$validPrepared.'/1/'.$name)];$private=$store->dir.'/local-'.hash('sha256',$target['root']);$request=['op'=>'set-write','root'=>$target['root'],'local_private'=>$private,'number'=>1,'files'=>$files,'expected_manifest'=>$sets->manifest($target,1)];$input=$root.'/swap.json';file_put_contents($input,json_encode($request,JSON_THROW_ON_ERROR));$env=getenv();$env['LD_PRELOAD']=$library;$child=proc_open([PHP_BINARY,__FILE__,'--crash-child',$input],[0=>['pipe','r'],1=>STDOUT,2=>STDERR],$pipes,null,$env);fclose($pipes[0]);$exit=proc_close($child);check($exit!==0&&!is_dir($target['root'].'/1')&&is_file($private.'/swap-1.json'),'SIGKILL after first rename leaves durable recoverable journal');
|
||||
$crashed=$jobs->create('upload',['targets'=>['test'],'number'=>1,'prepared'=>$validPrepared,'name'=>'Crash recovery']);$record=$store->read('jobs/'.$crashed['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$record['results'][0]['desired_manifest']=$desired;$store->write('jobs/'.$crashed['id'].'.json',$record);
|
||||
$jobs->interrupted($crashed['id']);clearstatcache();check(is_dir($target['root'].'/1')&&!is_file($private.'/swap-1.json'),'supervisor interruption automatically restores active folder after SIGKILL without user read');
|
||||
check($jobs->get($crashed['id'])['results'][0]['status']==='unknown'&&$sets->manifest($target,1)===$desired,'automatic journal recovery preserves complete set and unknown operation status');
|
||||
$unavailable=$jobs->create('upload',['targets'=>['missing'],'number'=>1,'prepared'=>$validPrepared]);$record=$store->read('jobs/'.$unavailable['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$store->write('jobs/'.$unavailable['id'].'.json',$record);$jobs->interrupted($unavailable['id']);$result=$jobs->get($unavailable['id'])['results'][0];check($result['status']==='unknown'&&($result['recovery_checked']??null)===false,'failed automatic recovery remains explicitly unconfirmed and unknown');
|
||||
echo "Review regression complete\n";
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__.'/backend.php';
|
||||
use PrelandAdmin\{Config,Jobs,Sets,IndpicSetStorage,Auth,Problem};
|
||||
function rejects(callable $call,string $message): void {try{$call();}catch(\Throwable $e){check(true,$message);return;}throw new RuntimeException('Accepted invalid input: '.$message);}
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":1}},"registration":{"default":"before","weights":{"before":1}}}}'),static fn()=>true),'old/new UTM alias conflict rejected');
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'),static fn()=>true),'invalid enum rejected');
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}'),static fn()=>true),'negative UTM weight rejected');
|
||||
rejects(static fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'),static fn()=>false),'incomplete default rejected');
|
||||
check(Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":0,"light":0}}}}'),static fn()=>true)===[],'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":1}}}}'),static fn()=>true),'default without weight variant rejected instead of runtime fallback');
|
||||
$kept=hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg');$badFiles=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename)$badFiles[$filename]=['data'=>base64_encode(file_get_contents($prepared.'/1/'.$filename)),'sha256'=>str_repeat('0',64)];
|
||||
rejects(fn()=>$transport->call(['op'=>'set-write','number'=>1,'files'=>$badFiles,'expected_manifest'=>$sets->manifest($target,1)]),'delivery hash mismatch rejects activation');check(hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg')===$kept,'failed set publication preserves previous working image');
|
||||
// Настоящий формат должен совпадать с расширением, даже если размеры верны.
|
||||
$jpg=$prepared.'/1/01_desk_1-source.jpg';$image=new Imagick();$image->newImage(1696,920,'red');$image->setImageFormat('png');file_put_contents($jpg,$image->getImagesBlob());$image->clear();
|
||||
rejects(fn()=>(new IndpicSetStorage($prepared))->validateDirectory(1),'extension and decoded image format mismatch rejected');
|
||||
echo "Security contract complete\n";
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM zoomzilla-api:issue328-591a67c
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --create-home --shell /bin/bash fixture && echo 'fixture:fixture-only-password' | chpasswd \
|
||||
&& mkdir -p /run/sshd /home/fixture/site && chown -R fixture:fixture /home/fixture/site
|
||||
ENTRYPOINT ["/bin/sh", "-c"]
|
||||
CMD ["ssh-keygen -A >/dev/null 2>&1; /usr/sbin/sshd -p 2222; php /work/admin/tests/backend-sftp.php"]
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__.'/backend-security.php';
|
||||
use PrelandAdmin\{Store,Transport,Config,Sets};
|
||||
$site='/home/fixture/site';file_put_contents($site.'/config.json',Config::encode((object)['host'=>'example.org','utm'=>(object)[]]));chown($site.'/config.json','fixture');
|
||||
$ssh=new phpseclib3\Net\SSH2('127.0.0.1',2222);$key=$ssh->getServerPublicHostKey();check(is_string($key),'real SSH host key available');
|
||||
$sftpTarget=$store->saveTarget(['id'=>'sftp','name'=>'SFTP fixture','domain'=>'fixture.invalid','type'=>'sftp','host'=>'127.0.0.1','port'=>2222,'user'=>'fixture','host_key'=>$key,'root'=>$site,'enabled'=>true],'fixture-only-password');
|
||||
$store->verifyTarget('sftp');$sftpTarget=$store->target('sftp');$remote=new Transport($store,$sftpTarget);
|
||||
$remote->test();check(true,'real SSH remote lock and permission preflight');$remoteRead=$remote->readConfig();check($remoteRead['config']->host==='example.org','real SSH config read');
|
||||
$remote->writeConfig($remoteRead['version'],Config::encode((object)['host'=>'ssh-written.example','utm'=>(object)[]]));check($remote->readConfig()['config']->host==='ssh-written.example','real SSH atomic CAS and reread');
|
||||
rejects(fn()=>$remote->writeConfig($remoteRead['version'],Config::encode((object)['host'=>'stale.example'])),'real remote stale version rejected');
|
||||
$bad=$sftpTarget;$bad['host_key']='SHA256:'.str_repeat('A',43);rejects(fn()=>(new Transport($store,$bad))->test(),'pinned SSH key mismatch rejected before login');
|
||||
// Восстановить проверенный набор после намеренной порчи формата security-тестом.
|
||||
$preparedRemote=$sets->prepareZip($zipPath,1);$sets->upload($sftpTarget,1,$preparedRemote,'SFTP проверка');check(is_file($site.'/1/01_desk_1-source.jpg'),'real SFTP image transfer, remote activation, checksum and decode');
|
||||
// Реальный обрыв ожидания после удалённой записи: fixture задерживает завершение команды.
|
||||
$wrapper=<<<'SH'
|
||||
#!/bin/sh
|
||||
/usr/bin/php "$@"
|
||||
if [ -f /home/fixture/delay-next-write ]; then
|
||||
op=$(/usr/bin/php -r '$p=json_decode(base64_decode($argv[1]),true);echo $p["op"]??"";' "$5")
|
||||
if [ "$op" = write ]; then rm /home/fixture/delay-next-write; sleep 3; fi
|
||||
fi
|
||||
SH;
|
||||
file_put_contents('/usr/local/bin/php',$wrapper);chmod('/usr/local/bin/php',0755);
|
||||
$beforeTimeout=$remote->readConfig();$timeoutJob=$jobs->create('apply',['targets'=>[['id'=>'sftp','version'=>$beforeTimeout['version']]],'changes'=>[['path'=>['host'],'value'=>'timeout-written.example']]]);
|
||||
file_put_contents('/home/fixture/delay-next-write','1');chown('/home/fixture/delay-next-write','fixture');putenv('ADMIN_TRANSPORT_TIMEOUT=1');$jobs->run($timeoutJob['id']);
|
||||
check($jobs->get($timeoutJob['id'])['results'][0]['status']==='unknown','real SSH timeout after write persists unknown result');
|
||||
putenv('ADMIN_TRANSPORT_TIMEOUT=60');$jobs->retry($timeoutJob['id'],['sftp']);$jobs->run($timeoutJob['id']);$retried=$jobs->get($timeoutJob['id']);check($retried['results'][0]['status']==='success','retry reads committed hash after real transport timeout');
|
||||
echo "SFTP integration complete\n";
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require __DIR__.'/backend-security.php';
|
||||
// Рабочий набор уже опубликован security fixture, сохраняем отдельный свежий subprocess на job.
|
||||
$env=getenv();$env['ADMIN_STATE_DIR']=$store->dir;$env['ADMIN_LOCAL_ROOTS']=$root.'/site';
|
||||
$queued1=$jobs->create('check',['targets'=>['test'],'number'=>1]);$queued2=$jobs->create('check',['targets'=>['test'],'number'=>1]);
|
||||
$onceLog=fopen($root.'/once.log','a');$once=proc_open([PHP_BINARY,dirname(__DIR__).'/bin/worker.php','--once'],[0=>['pipe','r'],1=>$onceLog,2=>$onceLog],$oncePipes,null,$env);fclose($oncePipes[0]);check(proc_close($once)===0,'worker once exits successfully');fclose($onceLog);
|
||||
$statuses=[$jobs->get($queued1['id'])['status'],$jobs->get($queued2['id'])['status']];sort($statuses);check($statuses===['completed','queued'],'worker once processes exactly one queued job');
|
||||
$record=$store->read('jobs/'.$queued1['id'].'.json');$record['status']='running';$record['results'][0]['status']='running';$record['results'][0]['desired_hash']='preserved-reconciliation-marker';$store->write('jobs/'.$queued1['id'].'.json',$record);$jobs->interrupted($queued1['id']);$interrupted=$store->read('jobs/'.$queued1['id'].'.json');check($interrupted['results'][0]['status']==='unknown'&&$interrupted['results'][0]['desired_hash']==='preserved-reconciliation-marker','crashed child becomes unknown and retains reconciliation state');
|
||||
$log=fopen($root.'/worker.log','a');$started=microtime(true);$process=proc_open([PHP_BINARY,dirname(__DIR__).'/bin/worker.php'],[0=>['pipe','r'],1=>$log,2=>$log],$pipes,null,$env);
|
||||
function awaitJob(string $id,int $seconds=25): array {global $jobs;$until=microtime(true)+$seconds;do{$job=$jobs->get($id);if($job['status']==='completed')return $job;usleep(100000);}while(microtime(true)<$until);throw new RuntimeException('Worker job timeout');}
|
||||
try{
|
||||
$first=$jobs->create('check',['targets'=>['test'],'number'=>1]);$one=awaitJob($first['id']);check($one['results'][0]['status']==='success','worker subprocess validates first 90-image job');
|
||||
while(microtime(true)-$started<65)sleep(1);
|
||||
$second=$jobs->create('check',['targets'=>['test'],'number'=>1]);$two=awaitJob($second['id']);check($two['results'][0]['status']==='success','worker still validates 90 images after process wall time exceeds 60 seconds');
|
||||
$status=proc_get_status($process);check($status['running'],'supervisor remains alive beyond Imagick time policy');
|
||||
echo "Worker isolation complete\n";
|
||||
}finally{proc_terminate($process);proc_close($process);fclose($log);}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__) . '/bootstrap.php';
|
||||
use PrelandAdmin\Config;
|
||||
use PrelandAdmin\Store;
|
||||
use PrelandAdmin\Transport;
|
||||
use PrelandAdmin\Jobs;
|
||||
function check(bool $value, string $message): void { if (!$value) throw new RuntimeException($message); echo "PASS $message\n"; }
|
||||
$root = sys_get_temp_dir() . '/preland-admin-' . bin2hex(random_bytes(6));
|
||||
mkdir($root, 0700, true); mkdir($root . '/site',0700); mkdir($root . '/state',0700);
|
||||
putenv('ADMIN_STATE_DIR=' . $root . '/state'); putenv('ADMIN_LOCAL_ROOTS=' . $root . '/site');
|
||||
$initial = (object)['host'=>'zoomzilla.ru','requisites'=>['one'],'custom'=>(object)['flag'=>true,'zero'=>0,'empty'=>(object)[]],'utm'=>(object)[]];
|
||||
file_put_contents($root . '/site/config.json', json_encode($initial));
|
||||
$store = new Store($root . '/state');
|
||||
$target = $store->saveTarget(['id'=>'test','name'=>'Тест','domain'=>'test.invalid','environment'=>'stage','type'=>'local','root'=>$root . '/site','enabled'=>true]);
|
||||
$store->verifyTarget('test');$target=$store->target('test');
|
||||
$transport = new Transport($store, $target);
|
||||
$before = $transport->readConfig();
|
||||
$next = Config::patch($before['config'], [['path'=>['host'],'value'=>'example.org']]);
|
||||
check($next->custom->flag === true && $next->custom->zero === 0 && $next->custom->empty instanceof stdClass, 'selected patch preserves unrelated values and types');
|
||||
$transport->writeConfig($before['version'], Config::encode($next));
|
||||
$after = $transport->readConfig(); check($after['config']->host === 'example.org','atomic save and reread');
|
||||
try { $transport->writeConfig($before['version'], Config::encode($initial)); throw new RuntimeException('missed conflict'); } catch (PrelandAdmin\Conflict $e) { check(true,'stale version conflicts'); }
|
||||
check(count($store->backups('test')) === 1,'private backup saved');
|
||||
$jobs = new Jobs($store);
|
||||
$job = $jobs->create('apply', ['targets'=>[['id'=>'test','version'=>$after['version']],['id'=>'missing','version'=>'x']], 'changes'=>[['path'=>['custom','flag'],'value'=>false]]]);
|
||||
$jobs->run($job['id']); $job=$jobs->get($job['id']);
|
||||
check($job['results'][0]['status']==='success' && $job['results'][1]['status']==='error','one target failure does not undo successful target');
|
||||
$restored=$transport->readConfig();
|
||||
$backup=$store->backups('test')[0];
|
||||
$job=$jobs->create('restore',['target'=>'test','id'=>$backup['id'],'version'=>$restored['version']]);$jobs->run($job['id']);
|
||||
check($jobs->get($job['id'])['results'][0]['status']==='success','backup restores with version protection');
|
||||
try { $store->saveTarget(['id'=>'escape','type'=>'local','root'=>'/etc','domain'=>'a.invalid']); throw new RuntimeException('unsafe root accepted'); } catch (PrelandAdmin\Problem $e) {check(true,'local root confined to explicit mounts');}
|
||||
try { Config::patch($initial,[['path'=>['__proto__'],'value'=>1]]); throw new RuntimeException('unsafe key accepted'); } catch(PrelandAdmin\Problem $e) {check(true,'unsafe patch path rejected');}
|
||||
echo "Backend contract complete\n";
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Запуск только в одноразовом Docker-контейнере от root, без host state/socket.
|
||||
set -eu
|
||||
test "$(id -u)" = 0
|
||||
test ! -e /srv/preland-admin
|
||||
test ! -e /usr/bin/docker
|
||||
base=/srv/preland-admin
|
||||
install -d -m 700 "$base" "$base/secrets" "$base/deploy" /tmp/catalog-victim
|
||||
install -d -m 700 -o 997 -g 987 "$base/state"
|
||||
printf 'unchanged\n' > /tmp/catalog-victim/original
|
||||
printf 'not executed\n' > "$base/deploy/export-catalog.php"
|
||||
cat > /usr/bin/docker <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' '{"host":"zoomzilla.ru","generated_at":"2026-10-25T00:00:00Z","groups":{"degr":[],"tp":[],"cons":[]}}'
|
||||
SH
|
||||
chmod 755 /usr/bin/docker
|
||||
# Унаследованный cwd моделирует доступ к корню bind mount внутри приложения,
|
||||
# минуя закрытый host-parent /srv/preland-admin.
|
||||
(cd "$base/state" && setpriv --reuid=997 --regid=987 --clear-groups ln -s /tmp/catalog-victim catalog.json)
|
||||
sed 's/\r$//' /work/admin/deploy/refresh-catalog.sh > /tmp/catalog-refresh.sh
|
||||
bash /tmp/catalog-refresh.sh
|
||||
test ! -L "$base/state/catalog.json"
|
||||
test -f "$base/state/catalog.json"
|
||||
test "$(stat -c %u /tmp/catalog-victim/original)" = 0
|
||||
test "$(cat /tmp/catalog-victim/original)" = unchanged
|
||||
test "$(find /tmp/catalog-victim -type f | wc -l)" = 1
|
||||
test "$(stat -c '%u:%g:%a' "$base/state/catalog.json")" = 997:987:600
|
||||
if setpriv --reuid=997 --regid=987 --clear-groups ls "$base/secrets" >/dev/null 2>&1; then
|
||||
echo 'FAIL: app UID can inspect root preparation directory' >&2
|
||||
exit 1
|
||||
fi
|
||||
echo 'PASS root catalog publisher replaces destination symlink without following it; victim and owner intact; preparation inaccessible to app UID'
|
||||
@@ -0,0 +1,76 @@
|
||||
/* Real HTTP + rendered regression: stale version, independent partial result,
|
||||
* invalid ZIP recovery, safe DOM. Run only against disposable Docker fixtures.
|
||||
* No browser network mocking. Browser plugin not available. */
|
||||
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=[],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()});});page.on('dialog',d=>d.accept());
|
||||
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'});
|
||||
const state=await page.request.get('/api/state').then(r=>r.json());
|
||||
const api=async(path,data)=>{const response=await page.request.post('/api/'+path,{data,headers:{'X-CSRF-Token':state.csrf}});const result=await response.json();assert.equal(result.ok,true,result.error);return result;};
|
||||
const read=async(id)=>page.request.get('/api/config?target='+encodeURIComponent(id)).then(r=>r.json());
|
||||
const poll=async(id)=>{for(let n=0;n<120;n++){const r=await page.request.get('/api/jobs?id='+id).then(r=>r.json());assert.ok(r.ok,r.error||'GET jobs вернул ошибку без описания');assert.ok(r.job,'GET jobs не вернул задание '+id);if(r.job.status==='completed')return r.job;await new Promise(r=>setTimeout(r,250));}throw Error('job timeout '+id);};
|
||||
const ids=state.targets.filter(t=>t.enabled).slice(0,2).map(t=>t.id);assert.equal(ids.length,2,'two disposable targets required');
|
||||
const originals=await Promise.all(ids.map(read));
|
||||
const externalHost='external-'+Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,10)+'.example.org';
|
||||
let primaryError=null;
|
||||
const restore=async()=>{const targets=await Promise.all(ids.map(async id=>({id,version:(await read(id)).version})));for(let i=0;i<ids.length;i++){const r=await api('apply',{targets:[targets[i]],changes:[{path:['host'],value:originals[i].config.host}]});const job=await poll(r.job.id);assert.equal(job.results[0].status,'success','fixture host restored');}};
|
||||
try{
|
||||
await page.locator('#source-target').selectOption(ids[0]);
|
||||
await page.getByLabel('Основной сайт',{exact:true}).waitFor();
|
||||
await page.locator('#targets input[value="'+ids[1]+'"]').check();
|
||||
await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));
|
||||
const altered=await api('apply',{targets:[{id:ids[1],version:originals[1].version}],changes:[{path:['host'],value:externalHost}]});
|
||||
assert.equal((await poll(altered.job.id)).results[0].status,'success');
|
||||
await page.getByLabel('Основной сайт',{exact:true}).fill('partial-check.example.org');
|
||||
const responsePromise=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');
|
||||
await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();
|
||||
const submitted=await (await responsePromise).json();
|
||||
const result=await poll(submitted.job.id);
|
||||
assert.deepEqual(result.results.map(r=>r.status),['success','conflict']);
|
||||
const job=page.locator('.job').filter({hasText:submitted.job.id});
|
||||
await job.getByText('Конфликт версии — перечитайте домен',{exact:true}).waitFor();
|
||||
assert.equal((await read(ids[0])).config.host,'partial-check.example.org');
|
||||
assert.equal((await read(ids[1])).config.host,externalHost,'stale UI did not overwrite external writer');
|
||||
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-conflict-partial.png',fullPage:true});
|
||||
await page.reload();
|
||||
await page.locator('#workspace').waitFor({state:'visible'});
|
||||
await page.locator('.job').filter({hasText:submitted.job.id}).waitFor();
|
||||
await page.locator('#source-target').selectOption(ids[0]);
|
||||
await page.getByLabel('Основной сайт',{exact:true}).waitFor();
|
||||
await page.locator('#set-add input[name=number]').fill(process.env.ZIP_NUMBER||'9');
|
||||
await page.locator('#set-add input[name=name]').fill('Тест ZIP');
|
||||
await page.locator('#set-add input[type=file]').setInputFiles({name:'invalid.zip',mimeType:'application/zip',buffer:Buffer.from('not a ZIP')});
|
||||
const uploadResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));
|
||||
await page.locator('#set-add button').click();
|
||||
const invalidResult=await (await uploadResponse).json();
|
||||
if(invalidResult.ok){const invalidJob=await poll(invalidResult.job.id);assert.equal(invalidJob.results[0].status,'error');await page.locator('#indpic-result-modal').waitFor({state:'visible'});await page.getByRole('button',{name:'Готово',exact:true}).click();}
|
||||
else await page.locator('#notice.alert-danger').waitFor({state:'visible'});
|
||||
assert.equal((await read(ids[0])).config.host,'partial-check.example.org','invalid ZIP kept working config');
|
||||
if(process.env.VALID_ZIP){
|
||||
await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);
|
||||
const response=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));
|
||||
await page.locator('#set-add button').click();const accepted=await(await response).json();assert.equal(accepted.ok,true,accepted.error);
|
||||
const done=await poll(accepted.job.id);assert.equal(done.results[0].status,'success',done.results[0].message);
|
||||
await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor();
|
||||
await page.getByRole('button',{name:'Готово',exact:true}).click();
|
||||
}
|
||||
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-zip-recovery.png',fullPage:true});
|
||||
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/admin-regression-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
|
||||
assert.deepEqual(errors,[]);console.log('PASS real partial success/conflict/external state/persistent history/invalid ZIP'+(process.env.VALID_ZIP?'/valid ZIP recovery':''));
|
||||
}catch(error){primaryError=error;}finally{
|
||||
const cleanupErrors=[];
|
||||
try{await restore();}catch(error){cleanupErrors.push(error);}
|
||||
try{await browser.close();}catch(error){cleanupErrors.push(error);}
|
||||
if(primaryError&&cleanupErrors.length)throw new AggregateError([primaryError,...cleanupErrors],'Основная ошибка browser-сценария; отдельно — ошибки восстановления fixture / закрытия browser',{cause:primaryError});
|
||||
if(primaryError)throw primaryError;
|
||||
if(cleanupErrors.length)throw new AggregateError(cleanupErrors,'Ошибка восстановления fixture / закрытия browser');
|
||||
}
|
||||
})().catch(e=>{console.error(e);process.exit(1);});
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Browser plugin not available; run only inside the supplied Docker Playwright image.
|
||||
* Required env: ADMIN_URL, ADMIN_USER, ADMIN_PASSWORD, UI_OUTPUT; optional SECOND_TARGET.
|
||||
*/
|
||||
const { chromium } = require('playwright-core');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true,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=[],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()});});
|
||||
await page.goto(process.env.ADMIN_URL || 'http://admin');
|
||||
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({state:'visible'});
|
||||
await page.locator('#source-target').selectOption({index:1});
|
||||
await page.getByLabel('Основной сайт', {exact:true}).waitFor();
|
||||
const host = page.getByLabel('Основной сайт', {exact:true});
|
||||
const original = await host.inputValue();
|
||||
const secondOriginal=process.env.SECOND_TARGET?await page.request.get('/api/config?target='+encodeURIComponent(process.env.SECOND_TARGET)).then(r=>r.json()).then(r=>r.config.host):null;
|
||||
await host.fill('ui-check.example.org');
|
||||
assert.match(await page.locator('#change-summary').innerText(), /host/);
|
||||
assert.equal(await page.locator('#targets input:checked').count(),1);
|
||||
if (process.env.SECOND_TARGET) await page.locator('#targets input[value="'+process.env.SECOND_TARGET+'"]').check();
|
||||
const apply = async () => {
|
||||
const pending=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');
|
||||
await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();
|
||||
const accepted=await(await pending).json();assert.equal(accepted.ok,true,accepted.error);
|
||||
await page.locator('.job').filter({hasText:accepted.job.id}).getByText('Сохранено и проверено',{exact:true}).first().waitFor();
|
||||
};
|
||||
await apply();
|
||||
await page.getByRole('button',{name:'Перечитать',exact:true}).click();
|
||||
await page.waitForFunction(()=>document.querySelector('[aria-label="Основной сайт"]')?.value==='ui-check.example.org');
|
||||
await host.fill(original);
|
||||
await apply();
|
||||
if(process.env.SECOND_TARGET){await page.locator('#source-target').selectOption(process.env.SECOND_TARGET);await host.waitFor();await host.fill(secondOriginal);await apply();}
|
||||
assert.equal(await page.locator('textarea').count(),0,'JSON textarea absent');
|
||||
assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');
|
||||
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-desktop.png',fullPage:true});
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.screenshot({path:(process.env.UI_OUTPUT||'/tmp')+'/admin-mobile.png',fullPage:true});
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,'no page-level horizontal overflow');
|
||||
fs.writeFileSync((process.env.UI_OUTPUT||'/tmp')+'/admin-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
|
||||
assert.deepEqual(errors,[]);assert.deepEqual(consoleMessages.filter(m=>!m.text.includes('status of 401')),[],'no unexpected browser console errors');
|
||||
await browser.close(); console.log('PASS real login/read/edit/apply/reload/restore + mobile + console');
|
||||
})().catch(e=>{console.error(e);process.exit(1);});
|
||||
@@ -0,0 +1,85 @@
|
||||
/* Real UI UTM regression. Disposable Docker fixtures a/b only.
|
||||
* Browser plugin not available. No route mocks or host-installed runtimes.
|
||||
* VALID_ZIP must contain set3; PUBLIC_A/B are optional fixture origins. */
|
||||
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 output=process.env.UI_OUTPUT||'/tmp';
|
||||
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()});});
|
||||
page.on('dialog',dialog=>dialog.accept());
|
||||
const read=async id=>{const r=await page.request.get('/api/config?target='+encodeURIComponent(id)).then(r=>r.json());assert.ok(r.ok,r.error);assert.ok(r.config,'config missing');return r;};
|
||||
const poll=async id=>{for(let n=0;n<480;n++){const r=await page.request.get('/api/jobs?id='+id).then(r=>r.json());assert.ok(r.ok,r.error);assert.ok(r.job,'job missing: '+id);if(r.job.status==='completed')return r.job;await new Promise(resolve=>setTimeout(resolve,250));}throw Error('job timeout: '+id);};
|
||||
const group=()=>page.locator('section.field-block').filter({has:page.getByRole('heading',{name:'indpic= UTM',exact:true})});
|
||||
const open=async id=>{await page.locator('#source-target').selectOption(id);await page.getByLabel('indpic default',{exact:true}).waitFor();await page.waitForFunction(()=>document.querySelector('#read-state').textContent.includes('версия'));};
|
||||
const chooseBoth=async()=>{await page.locator('#targets input[value="b"]').check();await page.waitForFunction(()=>!document.querySelector('#targets').textContent.includes('чтение версии'));};
|
||||
const submit=async()=>{const response=page.waitForResponse(r=>r.url().endsWith('/api/apply')&&r.request().method()==='POST');await page.getByRole('button',{name:'Применить к выбранным',exact:true}).click();const accepted=await(await response).json();assert.ok(accepted.ok,accepted.error);assert.ok(accepted.job);const job=await poll(accepted.job.id);const rendered=page.locator('.job').filter({hasText:job.id});await rendered.getByText('Завершено',{exact:false}).first().waitFor();return {job,rendered};};
|
||||
const setWeights=async weights=>{const section=group();const rows=section.locator('.weight-table tbody tr');while(await rows.count())await rows.last().getByRole('button',{name:'Удалить',exact:true}).click();for(const [variant,weight]of Object.entries(weights)){await section.getByRole('button',{name:'Добавить вариант',exact:true}).click();const row=rows.last();await row.getByLabel('indpic вариант',{exact:true}).selectOption(variant);await row.locator('input[type=number]').fill(String(weight));}};
|
||||
let originals=[],primaryError=null;
|
||||
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'});
|
||||
const state=await page.request.get('/api/state').then(r=>r.json());assert.ok(state.ok,state.error);
|
||||
for(const id of ['a','b'])assert.ok(state.targets.some(t=>t.id===id&&t.enabled),'enabled fixture '+id+' required');
|
||||
originals=await Promise.all(['a','b'].map(read));
|
||||
for(const original of originals){assert.ok(!Object.hasOwn(original.config.utm.indpic.weights,'3'),'original fixture must omit weights3 for rejection scenario');assert.notEqual(String(original.config.utm.indpic.default),'3');}
|
||||
await open('a');await chooseBoth();
|
||||
// Delivery is a real two-target ZIP form submission, not an API shortcut.
|
||||
assert.ok(process.env.VALID_ZIP,'VALID_ZIP=/fixture/valid-set3.zip required');
|
||||
await page.locator('#set-add input[name=number]').fill('3');
|
||||
await page.locator('#set-add input[name=name]').fill('UI UTM — набор 3');
|
||||
await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);
|
||||
const uploadResponse=page.waitForResponse(r=>r.url().endsWith('/api/sets/upload'));
|
||||
await page.locator('#set-add button').click();
|
||||
const upload=await(await uploadResponse).json();assert.ok(upload.ok,upload.error);
|
||||
const uploaded=await poll(upload.job.id);assert.deepEqual(uploaded.results.map(r=>r.status),['success','success']);
|
||||
await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor();
|
||||
await page.getByRole('button',{name:'Готово',exact:true}).click();
|
||||
// Wrong path must fail clearly, without altering either runtime config.
|
||||
await page.getByLabel('indpic default',{exact:true}).selectOption('3');
|
||||
const rejected=await submit();assert.deepEqual(rejected.job.results.map(r=>r.status),['error','error']);
|
||||
for(const result of rejected.job.results)assert.match(result.message,/Добавьте вариант 3 в таблицу весов indpic.*вес 0/);
|
||||
await rejected.rendered.getByText(/Добавьте вариант 3 в таблицу весов indpic/).first().waitFor();
|
||||
assert.deepEqual((await read('a')).config,originals[0].config);assert.deepEqual((await read('b')).config,originals[1].config);
|
||||
await page.screenshot({path:output+'/admin-utm-rejected-default.png',fullPage:true});
|
||||
// Correct path: a zero-weight row and explicit default3, only these two fields.
|
||||
await group().getByRole('button',{name:'Добавить вариант',exact:true}).click();
|
||||
const added=group().locator('.weight-table tbody tr').last();
|
||||
await added.getByLabel('indpic вариант',{exact:true}).selectOption('3');
|
||||
await added.locator('input[type=number]').fill('0');
|
||||
// Rejected submission cleared explicit field selection; select default again.
|
||||
await page.getByLabel('indpic default',{exact:true}).selectOption('3');
|
||||
assert.equal(await page.locator('#targets input:checked').count(),2);
|
||||
assert.match(await page.locator('#change-summary').innerText(),/utm.indpic.weights/);
|
||||
const applied=await submit();assert.deepEqual(applied.job.results.map(r=>r.status),['success','success']);
|
||||
const actuals=await Promise.all(['a','b'].map(read));
|
||||
for(let i=0;i<2;i++){const expected=structuredClone(originals[i].config);expected.utm.indpic.default=typeof originals[0].config.utm.indpic.default==='number'?3:'3';expected.utm.indpic.weights={...originals[0].config.utm.indpic.weights,'3':0};assert.deepEqual(actuals[i].config,expected,'only selected indpic fields changed on '+['a','b'][i]);}
|
||||
fs.writeFileSync(output+'/admin-utm-before-restore.json',JSON.stringify({job:applied.job.id,configs:{a:actuals[0].config,b:actuals[1].config}},null,2));
|
||||
await page.getByRole('button',{name:'Перечитать',exact:true}).click();await page.waitForFunction(()=>document.querySelector('[aria-label="indpic default"]')?.value==='3');
|
||||
assert.equal(await group().locator('.weight-table tbody tr').filter({has:page.locator('option[value="3"]:checked')}).count(),1);
|
||||
await page.screenshot({path:output+'/admin-utm-applied.png',fullPage:true});
|
||||
const publicEvidence=[];
|
||||
for(const origin of [process.env.PUBLIC_A,process.env.PUBLIC_B].filter(Boolean)){
|
||||
const response=await page.request.get(new URL('/?indpic=abcd3zzzz',origin).href);assert.equal(response.status(),200,'public HTML '+origin);const html=await response.text();assert.match(html,/data-source="[^\"]*\/3\/01_desk_1-source\.avif/,'public resolver selects uploaded set3');
|
||||
const image=await page.request.get(new URL('/3/01_desk_1-source.jpg',origin).href);assert.equal(image.status(),200,'public uploaded JPEG '+origin);const bytes=await image.body();assert.equal(bytes[0],0xff);assert.equal(bytes[1],0xd8);publicEvidence.push({origin,htmlStatus:response.status(),imageStatus:image.status(),bytes:bytes.length});
|
||||
}
|
||||
fs.writeFileSync(output+'/admin-utm-public.json',JSON.stringify(publicEvidence,null,2));
|
||||
assert.deepEqual(errors,[]);
|
||||
}catch(error){primaryError=error;}finally{
|
||||
const cleanupErrors=[];
|
||||
// The restoration itself uses actual form row removal/addition/default controls.
|
||||
for(let i=0;i<originals.length;i++)try{await open(['a','b'][i]);await setWeights(originals[i].config.utm.indpic.weights);await page.getByLabel('indpic default',{exact:true}).selectOption(String(originals[i].config.utm.indpic.default));const restored=await submit();assert.equal(restored.job.results[0].status,'success',restored.job.results[0].message);assert.deepEqual((await read(['a','b'][i])).config,originals[i].config,'UI restored full original config');}catch(error){cleanupErrors.push(error);}
|
||||
fs.writeFileSync(output+'/admin-utm-console.json',JSON.stringify({pageErrors:errors,console:consoleMessages},null,2));
|
||||
try{await browser.close();}catch(error){cleanupErrors.push(error);}
|
||||
if(primaryError&&cleanupErrors.length)throw new AggregateError([primaryError,...cleanupErrors],'UTM scenario failed; separate UI restore errors',{cause:primaryError});
|
||||
if(primaryError)throw primaryError;if(cleanupErrors.length)throw new AggregateError(cleanupErrors,'UI restore failed');
|
||||
}
|
||||
console.log('PASS real UI bulk ZIP3 / rejected default without weight / weights3=0+default3 to a+b / reread / preserve other fields / public origins / UI restore');
|
||||
})().catch(error=>{console.error(error);process.exit(1);});
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Прелендинги — UTM и наборы изображений</title><link rel="stylesheet" href="/assets/admin.css"><script src="/assets/admin.js" defer></script></head>
|
||||
<body><header class="navbar"><div class="container nav-inner"><strong>Прелендинги</strong><span>UTM и наборы изображений</span><button id="logout" class="btn btn-default" hidden>Выйти</button></div></header>
|
||||
<main class="container"><div id="notice" role="status" aria-live="polite" hidden></div>
|
||||
<section id="login-panel" class="panel login-panel"><h1>Вход в админку</h1><form id="login-form"><label>Логин<input class="form-control" name="username" autocomplete="username" required></label><label>Пароль<input class="form-control" name="password" type="password" autocomplete="current-password" required></label><button class="btn btn-primary" type="submit">Войти</button></form></section>
|
||||
<div id="workspace" hidden><h1>Настройка прелендингов</h1><nav class="tabs" aria-label="Разделы"><a href="#settings">Настройки</a><a href="#indpic">indpic</a><a href="#connections">Подключения</a><a href="#history">Журнал и резерв</a></nav>
|
||||
<section id="settings" class="panel"><h2>Настройки домена</h2><div class="toolbar"><label class="grow">Открытый домен<select id="source-target" class="form-control"><option value="">Выберите домен</option></select></label><button id="reload" class="btn btn-default" type="button">Перечитать</button></div><p id="read-state" class="text-muted">Выберите домен для чтения текущего config.json.</p><div id="warnings"></div><div id="config-fields"></div></section>
|
||||
<section class="panel" id="apply-panel"><h2>Применить изменения</h2><p>Отмечены только введённые или явно выбранные поля. Остальные значения каждого домена сохраняются.</p><div id="targets" class="target-options"></div><div class="toolbar"><button id="select-all-fields" class="btn btn-default">Выбрать все поля формы</button><button id="clear-fields" class="btn btn-default">Снять выбор полей</button></div><p id="change-summary" aria-live="polite">Поля не выбраны.</p><p id="target-summary" class="text-muted"></p><button id="apply" class="btn btn-primary" disabled>Применить к выбранным</button></section>
|
||||
<section id="indpic" class="panel"><h2>indpic= UTM</h2><p class="text-muted">Таблица открытого домена. ZIP и проверка SFTP используют выбранные выше цели. Загрузка не меняет default и веса.</p><div class="table-responsive"><table class="table table-bordered table-hover indpic-sets-table"><thead><tr><th>Номер / название</th><th class="indpic-default-column">Default</th><th class="indpic-check-column">Проверка</th><th>ZIP</th><th class="indpic-actions-column">Действия</th></tr></thead><tbody id="sets-body"></tbody></table></div><h3>Добавить Set</h3><form id="set-add" class="indpic-add-form"><label>№<input name="number" class="form-control indpic-number-input" type="number" min="1" max="9" required></label><label>Название Set<input name="name" class="form-control" maxlength="255"></label><label class="checkbox"><input name="sftp" type="checkbox"> Загружен по SFTP</label><label class="zip-field">ZIP<input name="file" type="file" accept=".zip,application/zip" class="form-control" required></label><button class="btn btn-default">Загрузить и добавить</button></form></section>
|
||||
<section id="connections" class="panel"><h2>Домены и подключения</h2><p class="text-muted">Удаление подключения оставляет сайт и файлы на хостинге. Секрет можно заменить; прочитать сохранённый секрет нельзя.</p><div id="connection-list"></div><h3 id="connection-title">Добавить подключение</h3><form id="connection-form"><div class="form-grid"><label>ID<input name="id" class="form-control" required pattern="[A-Za-z0-9_-]+"></label><label>Название<input name="name" class="form-control" required></label><label>Домен<input name="domain" class="form-control" required></label><label>Окружение<select name="environment" class="form-control"><option value="stage">stage</option><option value="production">production</option></select></label><label>Тип<select name="type" class="form-control"><option value="sftp">SFTP / SSH</option><option value="local">Локальная папка</option></select></label><label>Хост SSH<input name="host" class="form-control"></label><label>Порт<input name="port" type="number" min="1" max="65535" value="22" class="form-control"></label><label>Пользователь SSH<input name="user" class="form-control"></label><label>Корневая папка<input name="root" class="form-control" required></label><label>Проверенный ключ хоста<input name="host_key" class="form-control" autocomplete="off"></label><label>Новый секрет / пароль<input name="secret" type="password" class="form-control" autocomplete="new-password"></label><label class="checkbox"><input name="enabled" type="checkbox"> Включено для записи</label></div><div class="toolbar"><button class="btn btn-primary">Сохранить подключение</button><button class="btn btn-default" id="connection-new" type="button">Очистить форму</button></div></form></section>
|
||||
<section id="history" class="panel"><h2>Журнал и резервные копии</h2><button id="refresh-history" class="btn btn-default">Обновить журнал и резерв</button><h3>Резерв открытого домена</h3><div id="backups"></div><h3>Задания</h3><p class="text-muted">Успешные цели не повторяются. После конфликта перечитайте домен и проверьте изменения; неизвестный результат требует проверки фактического состояния.</p><div id="jobs" aria-live="polite"></div></section>
|
||||
</div></main>
|
||||
<dialog id="indpic-result-modal" aria-labelledby="result-title"><div class="modal-header"><button id="result-close" class="close" aria-label="Закрыть">×</button><h2 id="result-title">Результат проверки</h2></div><div id="result-body" class="modal-body"></div><div class="modal-footer"><button id="result-done" class="btn btn-default">Готово</button></div></dialog>
|
||||
</body></html>
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
/**
|
||||
* preland#22: проверка runtime indpic без сравнения с Git.
|
||||
* Контракт имён/размеров: api 591a67cc8302 components/indpic/IndpicSetStorage.php.
|
||||
* HTTP проверяет все обязательные файлы; только локальный режим может исключить лишние.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
function runtimeSetDimensions(): array
|
||||
{
|
||||
$files = [];
|
||||
for ($example = 1; $example <= 5; $example++) {
|
||||
foreach (['desk' => [[1696, 920], [200, 200]], 'mob' => [[400, 217], [60, 60]]] as $device => [$full, $thumb]) {
|
||||
foreach (['1-source' => $full, '2-result' => $full, '3-thumb' => $thumb] as $part => $dimensions) {
|
||||
foreach (['avif', 'webp', 'jpg'] as $extension) $files[sprintf('%02d_%s_%s.%s', $example, $device, $part, $extension)] = $dimensions;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $files;
|
||||
}
|
||||
|
||||
function runtimeSetNumbers(array $config): array
|
||||
{
|
||||
$settings = $config['utm']['indpic'] ?? ['default' => '1', 'weights' => ['1' => 1]];
|
||||
if (!is_array($settings)) throw new RuntimeException('Некорректный config utm.indpic');
|
||||
$number = static function ($value): int {
|
||||
if ((!is_string($value) && !is_int($value)) || !preg_match('/^[1-9]$/D', (string) $value)) throw new RuntimeException('Номер indpic должен быть 1–9');
|
||||
return (int) $value;
|
||||
};
|
||||
$sets = [$number(array_key_exists('default', $settings) ? $settings['default'] : '1')];
|
||||
$weights = $settings['weights'] ?? [];
|
||||
if (!is_array($weights)) throw new RuntimeException('Некорректные веса indpic');
|
||||
foreach ($weights as $variant => $weight) {
|
||||
if ((!is_int($weight) && !is_float($weight)) || !is_finite((float) $weight) || $weight < 0) throw new RuntimeException('Некорректный вес indpic');
|
||||
// Даже weight=0 доступен по явной ссылке indpic=abcdN и участвует в legacy random.
|
||||
$sets[] = $number($variant);
|
||||
}
|
||||
$sets = array_values(array_unique($sets)); sort($sets); return $sets;
|
||||
}
|
||||
|
||||
function runtimeLocalReader(string $root): Closure
|
||||
{
|
||||
return static function (string $path) use ($root): string {
|
||||
$bytes = @file_get_contents($root . '/' . $path);
|
||||
if ($bytes === false) throw new RuntimeException('Не прочитан runtime ресурс ' . $path);
|
||||
return $bytes;
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeHttpReader(string $origin): Closure
|
||||
{
|
||||
if (!preg_match('~^https://[A-Za-z0-9.-]+$~D', $origin)) throw new RuntimeException('Требуется HTTPS origin');
|
||||
return static function (string $path) use ($origin): string {
|
||||
$context = stream_context_create(['http' => ['timeout' => 30, 'follow_location' => 0, 'header' => "Cache-Control: no-cache\r\n"], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
|
||||
$bytes = @file_get_contents($origin . '/' . $path . '?runtime_check=' . bin2hex(random_bytes(8)), false, $context, 0, 20971521);
|
||||
if ($bytes === false || strlen($bytes) > 20971520 || !preg_match('~^HTTP/\S+ 200(?: |$)~', $http_response_header[0] ?? '')) throw new RuntimeException('Не прочитан runtime ресурс ' . $path);
|
||||
return $bytes;
|
||||
};
|
||||
}
|
||||
|
||||
function checkRuntimeSets(array $config, callable $read, ?string $localRoot = null): array
|
||||
{
|
||||
if (!class_exists(Imagick::class)) throw new RuntimeException('Для полной проверки indpic требуется Imagick');
|
||||
$sets = runtimeSetNumbers($config);
|
||||
$expected = runtimeSetDimensions();
|
||||
foreach ($sets as $set) {
|
||||
if ($localRoot !== null) {
|
||||
$directory = $localRoot . '/' . $set;
|
||||
if (is_link($directory) || !is_dir($directory)) throw new RuntimeException('Не найден каталог indpic ' . $set);
|
||||
$actual = [];
|
||||
foreach (new FilesystemIterator($directory, FilesystemIterator::SKIP_DOTS) as $file) {
|
||||
if ($file->isLink() || !$file->isFile()) throw new RuntimeException('Лишний элемент indpic ' . $set);
|
||||
$actual[] = $file->getFilename();
|
||||
}
|
||||
$names = array_keys($expected); sort($names); sort($actual);
|
||||
if ($actual !== $names) throw new RuntimeException('Набор indpic ' . $set . ' должен содержать ровно 90 обязательных файлов');
|
||||
}
|
||||
foreach ($expected as $name => [$width, $height]) {
|
||||
$path = $set . '/' . $name;
|
||||
$image = new Imagick();
|
||||
try {
|
||||
$image->readImageBlob($read($path));
|
||||
$format = strtolower($image->getImageFormat());
|
||||
$extension = pathinfo($name, PATHINFO_EXTENSION);
|
||||
$formats = ['jpg' => ['jpeg', 'jpg'], 'webp' => ['webp'], 'avif' => ['avif', 'heic']];
|
||||
if (!in_array($format, $formats[$extension], true) || $image->getNumberImages() !== 1 || $image->getImageWidth() !== $width || $image->getImageHeight() !== $height) throw new RuntimeException('Неверный формат/размер изображения');
|
||||
} catch (Throwable $error) {
|
||||
throw new RuntimeException('Невалидный runtime indpic ' . $path . ': ' . $error->getMessage(), 0, $error);
|
||||
} finally { $image->clear(); }
|
||||
}
|
||||
}
|
||||
return $sets;
|
||||
}
|
||||
|
||||
function verifyRemoteRuntimeSets(string $origin): void
|
||||
{
|
||||
$read = runtimeHttpReader($origin);
|
||||
$bytes = $read('config.json');
|
||||
$config = json_decode($bytes, true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($config)) throw new RuntimeException('Некорректный runtime config');
|
||||
$sets = checkRuntimeSets($config, $read);
|
||||
if ($read('config.json') !== $bytes) throw new RuntimeException('Runtime config изменился во время проверки; повторите проверку');
|
||||
fwrite(STDERR, 'OK: runtime indpic ' . implode(',', $sets) . '; все обязательные изображения декодированы, inventory лишних файлов по HTTP недоступен' . "\n");
|
||||
}
|
||||
|
||||
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
|
||||
try {
|
||||
if (($argv[1] ?? '') === '--remote') verifyRemoteRuntimeSets($argv[2] ?? '');
|
||||
else {
|
||||
$root = $argv[1] ?? dirname(__DIR__);
|
||||
$config = json_decode(runtimeLocalReader($root)('config.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
checkRuntimeSets($config, runtimeLocalReader($root), $root);
|
||||
echo "OK: локальный runtime indpic, ровно 90 файлов в каждом используемом наборе\n";
|
||||
}
|
||||
} catch (Throwable $error) { fwrite(STDERR, $error->getMessage() . "\n"); exit(1); }
|
||||
}
|
||||
+11
-4
@@ -2,6 +2,7 @@
|
||||
/** preland#11: проверенный состав ресурсов; исполняется до SFTP. */
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/brand-contract.php';
|
||||
require_once __DIR__ . '/check-runtime-sets.php';
|
||||
|
||||
function releaseAssetBytes(string $path, string $bytes): string
|
||||
{
|
||||
@@ -32,7 +33,8 @@ function releaseStaticFiles(string $root): array
|
||||
function validateReleaseAssets(string $root): array
|
||||
{
|
||||
$manifest = json_decode((string) file_get_contents($root . '/release/assets.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
$files = $manifest['files'] ?? [];
|
||||
// Исторические /N/ записи manifest — только архивный снимок, не runtime guard.
|
||||
$files = array_filter($manifest['files'] ?? [], static fn(string $path): bool => !preg_match('~^[0-9]+/~', $path), ARRAY_FILTER_USE_KEY);
|
||||
$brands = json_decode(file_get_contents($root . '/release/brands.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
foreach (array_keys($brands) as $domain) prelandBrand($root, $domain);
|
||||
$required = ['docs/logo.svg', 'docs/logo-alt.svg', 'docs/privacy_policy.pdf', 'docs/public_offer.pdf', 'docs/tariffs.pdf', 'docs/user_agreement.pdf'];
|
||||
@@ -40,7 +42,8 @@ function validateReleaseAssets(string $root): array
|
||||
if (!isset($files[$path])) throw new RuntimeException('В manifest отсутствует обязательный ресурс ' . $path);
|
||||
}
|
||||
$names = array_keys($files); sort($names, SORT_STRING);
|
||||
if ($names !== releaseStaticFiles($root)) throw new RuntimeException('Состав статических ресурсов отличается от manifest; синхронизируйте актуальный набор');
|
||||
$static = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool => !preg_match('~^[0-9]+/~', $path)));
|
||||
if ($names !== $static) throw new RuntimeException('Состав статических ресурсов отличается от manifest; синхронизируйте актуальный набор');
|
||||
foreach ($files as $path => $record) {
|
||||
if (!preg_match('~^[A-Za-z0-9._/-]+$~', $path) || str_contains($path, '..')) throw new RuntimeException('Недопустимый путь ресурса');
|
||||
$bytes = releaseAssetBytes($path, (string) file_get_contents($root . '/' . $path));
|
||||
@@ -55,7 +58,9 @@ function validateReleaseAssets(string $root): array
|
||||
|
||||
function releaseFiles(string $root): array
|
||||
{
|
||||
$files = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool => !preg_match('~^(?:[0-9]+/|docs/logo(?:-alt)?\.svg$)~', $path)));
|
||||
// Положительный контракт публичных ресурсов: admin/, его state/секреты и /N/ не поставляются.
|
||||
$files = array_values(array_filter(releaseStaticFiles($root), static fn(string $path): bool =>
|
||||
preg_match('~^(?:[^/]+\.css|docs/[^/]+\.pdf|favicon/[^/]+\.(?:ico|png|svg|webmanifest)|fonts/[^/]+\.(?:woff2?|ttf|otf)|images/(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+\.(?:avif|webp|jpe?g|png|gif|svg))$~D', $path) === 1));
|
||||
foreach (['.htaccess', 'index.php', 'i18n.php', 'template.php'] as $path) $files[] = $path;
|
||||
foreach (['components', 'messages'] as $directory) {
|
||||
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/' . $directory, FilesystemIterator::SKIP_DOTS));
|
||||
@@ -70,13 +75,15 @@ function releaseFiles(string $root): array
|
||||
function verifyRemoteReleaseAssets(array $files, string $base, bool $setsOnly): void
|
||||
{
|
||||
if (!preg_match('~^https://[A-Za-z0-9.-]+$~', $base)) throw new RuntimeException('Требуется HTTPS origin');
|
||||
verifyRemoteRuntimeSets($base);
|
||||
if ($setsOnly) return;
|
||||
if (!$setsOnly) {
|
||||
$brand = prelandBrand(dirname(__DIR__), (string) parse_url($base, PHP_URL_HOST));
|
||||
foreach ($brand['logos'] as $path => $record) $files[$path]['sha256'] = $record['sha256'];
|
||||
}
|
||||
$count = 0;
|
||||
foreach ($files as $path => $record) {
|
||||
if ($setsOnly && !preg_match('~^[0-9]+/~', $path)) continue;
|
||||
if (preg_match('~^[0-9]+/~', $path)) continue;
|
||||
$context = stream_context_create(['http' => ['timeout' => 30, 'header' => "Cache-Control: no-cache\r\n"], 'ssl' => ['verify_peer' => true, 'verify_peer_name' => true]]);
|
||||
$bytes = @file_get_contents($base . '/' . $path . '?release_sha=' . $record['sha256'], false, $context);
|
||||
if ($bytes === false || hash('sha256', releaseAssetBytes($path, $bytes)) !== $record['sha256']) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Единая админка прелендингов — preland#22
|
||||
|
||||
Дата: 25.10.2026. Статус: проект на согласование; реализация и выпуск ещё не выполнены.
|
||||
Дата: 25.10.2026. Владелец поручил выполнение до production без повторных подтверждений;
|
||||
реализация и приёмка ведутся по preland#22. Фактический выпуск отражается в задаче и KB.
|
||||
Реестр: http://git.dira89kv.beget.tech:3000/anton/preland/issues/22.
|
||||
Размещение и доступы: preland#21. Секреты в документ не входят.
|
||||
|
||||
@@ -70,8 +71,11 @@ modules/cpanel/views/settings/index.php, соответствующие дейс
|
||||
на основном сайте не проверено» в форме и результате; сохранение не выдаётся за
|
||||
проверку API. Это решение исполнителя сохраняет независимость редактора от API;
|
||||
соответствующие состояния включены в приёмку. Выдуманного списка групп нет.
|
||||
Сумма весов может быть нулевой: действующий
|
||||
преленд тогда использует default. Адрес сайта — допустимый HTTPS-хост без пути.
|
||||
Сумма весов может быть нулевой: действующий generic rnd выбирает первый ключ weights
|
||||
по порядку config.json (при пустой таблице — default); cons отдельно использует default.
|
||||
Назначаемый default indpic/degr/tp должен присутствовать в таблице весов, в том числе
|
||||
с нулевым весом. Для indpic проверяются все явно настроенные номера: нулевой вес не
|
||||
отменяет доступность номера по прямому UTM. Адрес сайта — допустимый HTTPS-хост без пути.
|
||||
|
||||
## Сохранение и массовые операции
|
||||
|
||||
@@ -106,7 +110,13 @@ SSH доступен на начальных REG.RU-хостах и позвол
|
||||
из существующего IndpicSetStorage. Лимиты ZIP, распаковки и времени задаются сервером.
|
||||
Проверенный набор передаётся в непубличную подготовительную папку на целевом хосте,
|
||||
проверяются все файлы/SHA-256, затем переключается каталог с резервом и откатом при
|
||||
ошибке. Папки подготовки/резерва никогда не обслуживаются веб-сервером.
|
||||
ошибке. Для exact local bind mount подготовка находится в скрытой папке того же mount
|
||||
с правами0700, публичный Apache работает другим UID; обязательны прямой и внешний
|
||||
HTTP403 probe. Резерв находится в приватном state вне webroot; remote SFTP использует
|
||||
sibling-папку. Папки подготовки/резерва никогда не обслуживаются веб-сервером.
|
||||
Переключение имеет два rename и durable journal: живой supervisor восстанавливает
|
||||
каталог после аварии child, перезапуск — при следующей операции транспорта. Это
|
||||
восстановление после прерывания с кратким окном, а не Linux atomic exchange.
|
||||
Для уже загруженного по SFTP набора файлы проверяются тем же валидатором.
|
||||
|
||||
Загрузка не меняет default и веса автоматически. Назначение default — явная операция
|
||||
@@ -169,6 +179,7 @@ traversal, лимиты, ошибка публикации и сохранени
|
||||
На усмотрение исполнителя: PHP-библиотека SFTP, детали приватного файлового реестра,
|
||||
лимиты и хранение заданий, распараллеливание в пределах ресурсов. Выбор подтверждается
|
||||
тестами и совместимостью с существующим хостингом; установки на Windows-хост нет.
|
||||
Пока не выполнены: согласование этого проекта, реализация, проверки, настройка DNS/TLS,
|
||||
выдача ограниченных доступов и запуск. Секреты/возможности среды требуют фактической
|
||||
проверки; исторический успешный SFTP-вход не доказывает готовность центральной админки.
|
||||
Согласование выполнения получено25.10. Код и проверки фиксируются в задачах плана;
|
||||
неполученный доступ, непроверенный обязательный сценарий и незавершённый выпуск
|
||||
не скрываются закрытием задачи. Исторический успешный SFTP-вход не доказывает
|
||||
готовность центральной админки. Runtime/секреты проверяются на фактической среде.
|
||||
@@ -50,9 +50,9 @@ foreach ([
|
||||
|
||||
$workflow = (string) file_get_contents($root . '/.gitea/workflows/deploy.yml');
|
||||
foreach ([
|
||||
'"$PRELAND_URL/1/01_desk_1-source.avif"',
|
||||
'"$PRELAND_URL/2/01_desk_1-source.avif"',
|
||||
'2/01_mob_1-source.avif',
|
||||
'php bin/release-assets.php --stage-sets "$PRELAND_URL"',
|
||||
'php bin/release-assets.php --remote "$PRELAND_URL"',
|
||||
'php-imagick',
|
||||
'Remove obsolete demo directories',
|
||||
] as $requiredStageSmokeCheck) {
|
||||
if (!str_contains($workflow, $requiredStageSmokeCheck)) {
|
||||
@@ -60,6 +60,14 @@ foreach ([
|
||||
}
|
||||
}
|
||||
|
||||
// #22: реальный runtime config выбирает наборы; удаление неиспользуемого /2/
|
||||
// не должно блокировать выпуск. Обязательные файлы/размеры проверяет guard.
|
||||
require_once $root . '/bin/release-assets.php';
|
||||
foreach (releaseFiles($root) as $file) {
|
||||
if (preg_match('~^(admin/|config\.json$|[0-9]+/)~', $file)) $failures[] = 'Runtime/приватный файл в поставке: ' . $file;
|
||||
}
|
||||
if (runtimeSetNumbers(['utm' => ['indpic' => ['default' => '3', 'weights' => ['3' => 1]]]]) !== [3]) $failures[] = 'Наборы должны определяться фактическим config';
|
||||
|
||||
if (!str_contains($workflow, '|[0-9]+/)')) {
|
||||
$failures[] = 'Stage-деплой не должен перезаписывать корневые наборы /N/: stage владеет их содержимым.';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
/** preland#22: runtime-контент независим от Git, контракт изображений обязателен. */
|
||||
declare(strict_types=1);
|
||||
require_once dirname(__DIR__) . '/bin/check-runtime-sets.php';
|
||||
require_once dirname(__DIR__) . '/bin/release-assets.php';
|
||||
$root = dirname(__DIR__);
|
||||
$fixture = sys_get_temp_dir() . '/preland22-' . bin2hex(random_bytes(6));
|
||||
mkdir($fixture);
|
||||
mkdir($fixture . '/1');
|
||||
$assertFails = static function (callable $operation): void {
|
||||
try { $operation(); } catch (RuntimeException $expected) { return; }
|
||||
throw new LogicException('Повреждённый runtime-контракт принят');
|
||||
};
|
||||
try {
|
||||
foreach (runtimeSetDimensions() as $name => $dimensions) copy($root . '/1/' . $name, $fixture . '/1/' . $name);
|
||||
$config = ['utm' => ['indpic' => ['default' => '1', 'weights' => ['1' => 1]]]];
|
||||
// Замена изображения корректным новым изображением не требует Git SHA.
|
||||
$image = new Imagick($fixture . '/1/01_desk_1-source.jpg');
|
||||
$image->setImageProperty('comment', 'runtime replacement preland#22');
|
||||
$image->writeImage($fixture . '/1/01_desk_1-source.jpg');
|
||||
$image->clear();
|
||||
if (hash_file('sha256', $fixture . '/1/01_desk_1-source.jpg') === hash_file('sha256', $root . '/1/01_desk_1-source.jpg')) throw new LogicException('Fixture не изменён');
|
||||
checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture);
|
||||
$requests = [];
|
||||
checkRuntimeSets($config, static function (string $path) use ($fixture, &$requests): string { $requests[] = $path; return runtimeLocalReader($fixture)($path); });
|
||||
if (count($requests) !== 90) throw new LogicException('HTTP-путь обязан прочитать все 90 файлов');
|
||||
$bytes = file_get_contents($fixture . '/1/01_desk_1-source.jpg');
|
||||
file_put_contents($fixture . '/1/01_desk_1-source.jpg', '<html>HTTP 200</html>');
|
||||
$assertFails(fn() => checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture));
|
||||
file_put_contents($fixture . '/1/01_desk_1-source.jpg', $bytes);
|
||||
$wrong = new Imagick(); $wrong->newImage(1, 1, 'red', 'jpeg'); $wrong->writeImage($fixture . '/1/01_desk_1-source.jpg'); $wrong->clear();
|
||||
$assertFails(fn() => checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture));
|
||||
file_put_contents($fixture . '/1/01_desk_1-source.jpg', $bytes);
|
||||
file_put_contents($fixture . '/1/extra.txt', 'extra');
|
||||
$assertFails(fn() => checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture));
|
||||
unlink($fixture . '/1/extra.txt');
|
||||
unlink($fixture . '/1/05_mob_3-thumb.avif');
|
||||
$assertFails(fn() => checkRuntimeSets($config, runtimeLocalReader($fixture), $fixture));
|
||||
copy($root . '/1/05_mob_3-thumb.avif', $fixture . '/1/05_mob_3-thumb.avif');
|
||||
foreach (['0', '10', '../1', false, null] as $default) {
|
||||
$bad = $config; $bad['utm']['indpic']['default'] = $default;
|
||||
$assertFails(fn() => checkRuntimeSets($bad, runtimeLocalReader($fixture), $fixture));
|
||||
}
|
||||
$bad = $config; $bad['utm']['indpic']['weights']['2'] = 1;
|
||||
$assertFails(fn() => checkRuntimeSets($bad, runtimeLocalReader($fixture), $fixture));
|
||||
$bad = $config; $bad['utm']['indpic']['default'] = '2';
|
||||
$assertFails(fn() => checkRuntimeSets($bad, runtimeLocalReader($fixture), $fixture));
|
||||
checkRuntimeSets([], runtimeLocalReader($fixture), $fixture); // штатный fallback /1/
|
||||
foreach (releaseFiles($root) as $file) if (preg_match('~^(admin/|config\.json$|[0-9]+/)~', $file)) throw new LogicException('Приватный/runtime файл в поставке');
|
||||
echo "OK: runtime replacement, decode, dimensions, missing/extra files, default, active links, release isolation\n";
|
||||
} finally {
|
||||
foreach (glob($fixture . '/1/*') as $file) unlink($file);
|
||||
rmdir($fixture . '/1'); rmdir($fixture);
|
||||
}
|
||||
Reference in new issue
Block a user