api#326 Числовые UTM и новый шум каждого перехода

This commit is contained in:
Panfilov Konstantin committed 2026-10-25 02:05:48 +03:00
1 parent f30742c2fb
commit 81e189ca67
16 files changed
+303 -160

No files matched your search

+5 -28
View File
@@ -42,29 +42,6 @@ jobs:
# сначала перенести актуальный снимок в Git по задаче.
php bin/release-assets.php --stage-sets "$PRELAND_URL"
- name: Preserve host config and add focus if absent
env:
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }}
SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }}
SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }}
run: |
set -eu
[ -n "$SFTP_HOST" ] && [ -n "$SFTP_USER" ] && [ -n "$SFTP_PASS" ] && [ -n "$SFTP_KNOWN_HOSTS" ] || { echo "не заданы секреты ZOOMPIXEL_SFTP_*"; exit 1; }
umask 077
known_hosts=$(mktemp); batch=$(mktemp); config=$(mktemp)
trap 'rm -f "$known_hosts" "$batch" "$config"' EXIT
printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts"
printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
changed=0
php bin/ensure-focus-config.php "$config" || changed=$?
case "$changed" in
0) ;;
10) printf '%s\n' "put -p $config $REMOTE_DIR/config.json" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST" ;;
*) exit "$changed" ;;
esac
- name: Apply approved domain brand only if needed
env:
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
@@ -129,9 +106,9 @@ jobs:
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
echo "залито файлов: $(wc -l < "$files")"
# Сначала совместимый PHP-код: он понимает старый host-config без chb.
# Затем меняем только согласованные consent-ключи, не заменяя весь config снимком Git.
- name: Migrate consent UTM in preserved host config
# api#326: сначала совместимый PHP-код, затем точечная миграция UTM.
# Остальные поля владельца и их JSON-типы сохраняются.
- name: Migrate numeric UTM in preserved host config
env:
SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }}
SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }}
@@ -146,7 +123,7 @@ jobs:
printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch"
SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST"
changed=0
php bin/ensure-cons-config.php "$config" || changed=$?
php bin/ensure-utm-config.php "$config" || changed=$?
case "$changed" in
0) ;;
10) revision=$(git rev-parse HEAD)
@@ -165,7 +142,7 @@ jobs:
printf '%s' "$page" | grep -q 'claim?key=' || { echo "переход не на claim"; exit 1; }
curl -fsS --max-time 30 "$PRELAND_URL/1/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован первый набор примеров"; exit 1; }
curl -fsS --max-time 30 "$PRELAND_URL/2/01_desk_1-source.avif" -o /dev/null || { echo "не опубликован второй набор примеров"; exit 1; }
page2=$(curl -fsS --max-time 30 "$PRELAND_URL/?indpic=2&nc=$(date +%s)")
page2=$(curl -fsS --max-time 30 "$PRELAND_URL/?indpic=abcd2efghi&nc=$(date +%s)")
printf '%s' "$page2" | grep -q '2/01_mob_1-source.avif' || { echo "indpic=2 не использует второй набор примеров"; exit 1; }
php bin/release-assets.php --remote "$PRELAND_URL"
php bin/release-brand.php --status "$PRELAND_URL"
+20 -24
View File
@@ -6,34 +6,30 @@
маленькая заглушка `Yii::t` в `i18n.php`.
Активные production: `https://zoompixel.ru`, `https://zoompixel.online`; текущая цель выката — только `https://zoomstage.online`. Пример ссылки входа:
`https://zoomstage.online/?indpic=1&theme=dark&degr=2&registration=after&bchoice=a&tp=1&utm_source=test&subid=CLICK1`.
`https://zoomstage.online/?indpic=e9st1mm12b&theme=e9st1mm12b&degr=e9st2mm12b&reg=e9st2mm12b&chob=e9st2mm12b&tp=e9st1mm12b&utm_source=test&subid=CLICK1`.
## Как работает
### Метки
### Метки (api#326)
`index.php` разбирает параметры адреса по разделу `utm` из `config.json`. Для каждой настроенной метки:
Из публичного значения учитывается только пятый символ: цифра1–9 выбирает вариант,0 на Preland выбирает его по прежним `weights`. Отсутствующий, короткий, массивный и невалидный ввод получает `default`. Открытые `1`, `rnd`, `dark`, старые имена `registration/bchoice/chb` больше не выбирают варианты. Точная длина входной строки не ограничена; Unicode-шум не изменяет позицию управляющего символа.
- значение из адреса принимается, если оно есть в `weights`;
- `rnd` — случайное значение по весам (`weights`), так делится трафик между вариантами;
- неизвестное значение отбрасывается, отсутствующее — заменяется на `default`.
| Метка | Числовые значения |
|---|---|
| `indpic` | номер настроенного набора1–9 (`/1/`, `/2/` и т.д.) |
| `theme` |1 dark,2 light |
| `degr` | номер настроенной группы1–9 |
| `reg` |1 before (PIN до оплаты),2 after |
| `chob` |1 без попапа банка,2 с попапом |
| `tp` | номер настроенной тарифной группы1–9 |
| `cons` | номер группы согласий1–9; существование проверяет API |
| `focus` |1 face,2 center |
Ненастроенные параметры (`utm_source`, `keyword`, `subid` и любые другие) проходят как есть. Перед переходом
Preland всегда формирует полный resolved-набор своих восьми меток: явное допустимое значение остаётся, `rnd`
разрешается по весам, отсутствующее получает `default`. Вместе с внешними рекламными параметрами этот набор
уходит в `claim`, а затем сайт переносит его в `upscale`; ни один из восьми параметров не должен пропадать только
потому, что был назначен default.
`config.json` хранит публичные числовые `weights/default`. Внутри PHP сохраняются прежние semantic enum. Runtime-конфиг мигрируется `bin/ensure-utm-config.php`: только согласованные UTM, с сохранением весов, default и JSON-типов соседних полей. Скрипт идемпотентен; старые focus/cons patch-скрипты больше не вызываются доставкой.
| Метка | Что задаёт | Где |
|---|---|---|
| `indpic` | картинка-пример на преленде | корневой каталог сета `/1/`, `/2/` и т. д. |
| `theme` | `light` / `dark` | преленд и сайт |
| `degr` | пресет ухудшения превью «До» | сайт (#42) |
| `registration` | `before` — PIN до оплаты, `after` — после | сайт, страница результата |
| `bchoice` | попап выбора банка (`a` — показать) | сайт, оплата (#44) |
| `cons` | номер группы согласий на Join; `rnd` выбирает группу по весам, исходно 1 с вероятностью 100%, default — 2 | сайт, оплата |
| `tp` | группа тарифов | сайт, cpanel «Группы тарифов (tp)» |
| `focus` | первоначальная область лупы Join: `center`/`face` | сайт, cpanel после `tp` |
Перед каждым переходом Preland создаёт для всех восьми меток новый шум из10 ASCII букв/цифр, с уже выбранной цифрой в пятой позиции. Retry и recovery меняют только шум; вариант повторно не выбирается. Выбранные цифры сохраняются в upload record. Старые записи IndexedDB адаптируются только на пути recovery, без публичных alias старого URL.
Чужие параметры (`utm_source`, `keyword`, `subid` и любые другие) сохраняют значения после обычного URL-кодирования. Технические `key/id/stage/shown_theme` не маскируются; внешний `key` не может подменить одноразовый ключ загрузки. `shown_theme` берётся из фактически показанной темы непосредственно при переходе.
### Загрузка и переход на сайт
@@ -99,7 +95,7 @@ Preland хранит наборы ровно как Index, поэтому их
N/05_...
```
`indpic=N` выбирает корневой каталог `/N/`. Набор копируется с Index в корень
`indpic=abcdNefghi` выбирает корневой каталог `/N/`. Набор копируется с Index в корень
домена целиком: без переименования, вложения в `images/` и промежуточных
каталогов. Устаревшие `images/demo*` не являются частью поставки.
@@ -145,8 +141,8 @@ N/05_...
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'https://<host>/api/images/public/claim?key=x'
# 302 на https://<host>/ — точка передачи на сайте есть
```
В браузере: открыть `https://<домен>/?indpic=1&theme=dark&subid=TEST`, выбрать фото — адрес сменится на
`https://<host>/public/upscale?id=…&indpic=1&theme=dark&subid=TEST…` (без `key=`), пойдёт обработка.
В браузере: открыть `https://<домен>/?indpic=abcd1efghi&theme=abcd1efghi&subid=TEST`, выбрать фото — адрес сменится на
`https://<host>/public/upscale?id=…&indpic=<новый шум с1>&theme=<новый шум с1>&subid=TEST…` (без `key=`), пойдёт обработка.
Это настоящая загрузка и обработка на сайте.
## Автодеплой (Gitea Actions)
+34
View File
@@ -0,0 +1,34 @@
<?php
/** api#326: только согласованные имена/enum UTM; чужие поля/JSON-типы сохраняются. */
declare(strict_types=1);
if ($argc!==2) { fwrite(STDERR,"Использование: php bin/ensure-utm-config.php config.json\n"); exit(2); }
try {
$path=$argv[1]; $raw=file_get_contents($path);
$config=json_decode($raw,false,512,JSON_THROW_ON_ERROR);
if (!$config instanceof stdClass || !($config->utm ?? null) instanceof stdClass) throw new RuntimeException('Отсутствует объект utm');
$before=serialize($config);
foreach (['registration'=>'reg','bchoice'=>'chob'] as $old=>$new) {
if (property_exists($config->utm,$old)) {
if (property_exists($config->utm,$new)) throw new RuntimeException('Конфликт старого и нового UTM: '.$new);
$config->utm->$new=$config->utm->$old; unset($config->utm->$old);
}
}
foreach (['theme'=>['dark'=>'1','light'=>'2'],'reg'=>['before'=>'1','after'=>'2'],'chob'=>['b'=>'1','a'=>'2'],'focus'=>['face'=>'1','center'=>'2']] as $key=>$map) {
if (!property_exists($config->utm,$key)) continue;
$setting=$config->utm->$key;
if (!$setting instanceof stdClass || !($setting->weights ?? null) instanceof stdClass || !(is_string($setting->default ?? null) || is_int($setting->default ?? null))) throw new RuntimeException('Некорректный UTM: '.$key);
$weights=new stdClass;
foreach ($setting->weights as $variant=>$weight) {
$digit=$map[$variant] ?? (string)$variant;
if (!in_array($digit,['1','2'],true) || property_exists($weights,$digit)) throw new RuntimeException('Неоднозначный вариант UTM: '.$key);
$weights->$digit=$weight;
}
$default=$map[$setting->default] ?? $setting->default;
if (!in_array((string)$default,['1','2'],true)) throw new RuntimeException('Некорректный default UTM: '.$key);
$setting->weights=$weights; $setting->default=$default;
}
if (serialize($config)===$before) { echo "Числовые UTM уже настроены; config не изменён.\n"; exit(0); }
$encoded=json_encode($config,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR)."\n";
if (file_put_contents($path,$encoded,LOCK_EX)===false) throw new RuntimeException('Не удалось записать config');
echo "Мигрированы только согласованные имена и enum UTM.\n"; exit(10);
} catch(Throwable $error) { fwrite(STDERR,$error->getMessage()."\n"); exit(2); }
+4 -14
View File
@@ -3,9 +3,9 @@ require_once __DIR__ . '/browser-upload.php';
require __DIR__ . '/upload-error.php';
echo '<script>', require __DIR__ . '/navigation-guard.php', '</script>';
echo '<script>', require __DIR__ . '/upload-recovery.php', '</script>';
echo '<script>', require __DIR__ . '/utm-handoff.php', '</script>';
$errorMessage = Yii::t('app', 'Возникла ошибка');
$utmJson = json_encode($UTM ?? [], JSON_UNESCAPED_UNICODE);
$createApiUrl = ($apiHost ?? '') . '/api/images/public/create';
$uploadPolicyApiUrl = ($apiHost ?? '') . '/api/images/public/upload-policy';
@@ -70,19 +70,9 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
}
};
const utmData = <?= $utmJson ?>;
const redirectUrlBase = <?= json_encode($redirectUrlBase, JSON_UNESCAPED_UNICODE) ?>;
const redirectUrlUtm = <?= json_encode($redirectUrlUtm, JSON_UNESCAPED_UNICODE) ?>;
// Сохранённая до перехода на cons загрузка не должна вернуть legacy chb.
const normalizeConsentRedirect = query => {
const params = new URLSearchParams(query || '');
params.delete('chb');
const value = params.get('cons');
if (!/^[1-9][0-9]{0,9}$/.test(value || '') || Number(value) > 2147483647) {
params.set('cons', String(utmData.cons || '2'));
}
return params.toString();
};
const utmDigits = <?= json_encode($redirect_digits ?? [], JSON_UNESCAPED_UNICODE) ?>;
const createApiUrl = <?= json_encode($createApiUrl, JSON_UNESCAPED_UNICODE) ?>;
const uploadPolicyApiUrl = <?= json_encode($uploadPolicyApiUrl, JSON_UNESCAPED_UNICODE) ?>;
@@ -118,7 +108,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
let leavingUploadPage = false;
let uploadAttempt = 0;
const recovery = window.ZoomzillaUploadRecovery.create({scope: 'preland', target: createApiUrl});
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm});
const metadata = () => ({actionUrl: createApiUrl, redirectBase: redirectUrlBase, redirectUtm: redirectUrlUtm, utmDigits: {...utmDigits}});
const clearRecovery = () => recovery.clear().catch(() => {});
let uploadDots = 1;
let uploadDotsTimer = null;
@@ -213,7 +203,7 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
function redirectToTour(record) {
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
const redirectQuery = normalizeConsentRedirect(record.meta.redirectUtm);
const redirectQuery = window.ZoomzillaUtmHandoff.query(record.meta, utmDigits);
if (redirectQuery) targetUrl += '&' + redirectQuery;
const shownTheme = window.ZoomzillaTheme.current();
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
+72
View File
@@ -0,0 +1,72 @@
<?php
/** api#326: внешняя граница UTM; semantic-контекст шаблонов остаётся прежним. */
declare(strict_types=1);
function prelandUtmEnums(): array
{
return ['theme'=>['1'=>'dark','2'=>'light'], 'reg'=>['1'=>'before','2'=>'after'], 'chob'=>['1'=>'b','2'=>'a'], 'focus'=>['1'=>'face','2'=>'center']];
}
/** Совместимость host-config при доставке кода до точечной миграции. */
function prelandNumericUtmConfig(array $config): array
{
$settings = is_array($config['utm'] ?? null) ? $config['utm'] : [];
foreach (['registration'=>'reg','bchoice'=>'chob'] as $old=>$new) {
if (!isset($settings[$new]) && isset($settings[$old])) $settings[$new] = $settings[$old];
unset($settings[$old]);
}
unset($settings['chb']);
foreach (prelandUtmEnums() as $key=>$map) {
if (!is_array($settings[$key] ?? null)) continue;
$weights=[];
foreach ((array)($settings[$key]['weights'] ?? []) as $variant=>$weight) {
$digit=array_search((string)$variant,$map,true);
$weights[$digit === false ? $variant : $digit]=$weight;
}
$settings[$key]['weights']=$weights;
$default=$settings[$key]['default'] ?? null;
$digit=array_search($default,$map,true);
if ($digit !== false) $settings[$key]['default']=(string)$digit;
}
$config['utm']=$settings;
return $config;
}
/** Выбирает random один раз, до рендера/сохранения загрузки. */
function prelandResolveUtm(array $query, array $config): array
{
$config=prelandNumericUtmConfig($config);
$defaults=['indpic'=>'1','theme'=>'2','degr'=>'1','reg'=>'2','chob'=>'2','tp'=>'1','cons'=>'2','focus'=>'1'];
$enums=prelandUtmEnums(); $digits=[]; $context=$query;
foreach (['registration','bchoice','chb'] as $old) unset($context[$old]);
foreach ($defaults as $key=>$fallback) {
unset($context[$key]);
$settings=$config['utm'][$key] ?? [];
$valid=static fn($digit): bool => preg_match('/^[1-9]$/D',(string)$digit)===1
&& (!isset($enums[$key]) || isset($enums[$key][$digit]))
&& (in_array($key,['cons','theme','reg','chob','focus'],true) || isset($settings['weights'][$digit]));
$default=(string)($settings['default'] ?? $fallback);
if (!$valid($default)) $default=$fallback;
$value=$query[$key] ?? null;
$digit=is_string($value) && preg_match('/\A.{4}([0-9])/us',$value,$match) ? $match[1] : null;
if ($digit==='0') {
$weights=[];
foreach ((array)($settings['weights'] ?? []) as $variant=>$weight) {
if ($valid($variant) && is_numeric($weight) && is_finite((float)$weight) && (float)$weight>0) $weights[$variant]=(float)$weight;
}
$total=array_sum($weights); $digit=$default;
if ($total>0 && is_finite($total)) {
$point=mt_rand()/(mt_getrandmax()+1.0)*$total;
foreach ($weights as $variant=>$weight) {
if ($point<$weight) { $digit=(string)$variant; break; }
$point-=$weight;
}
}
}
if (!$valid($digit ?? '')) $digit=$default;
$digits[$key]=$digit;
$internal=['reg'=>'registration','chob'=>'bchoice'][$key] ?? $key;
$context[$internal]=$enums[$key][$digit] ?? $digit;
}
return ['digits'=>$digits,'context'=>$context];
}
+41
View File
@@ -0,0 +1,41 @@
<?php
/** api#326: новый шум при каждой навигации; recovery хранит выбранные цифры. */
return <<<'JS'
(function () {
const keys = ['indpic','theme','degr','reg','chob','tp','cons','focus'];
const enums = {theme:{dark:'1',light:'2'},reg:{before:'1',after:'2'},chob:{b:'1',a:'2'},focus:{face:'1',center:'2'}};
const legacy = {reg:'registration',chob:'bchoice'};
const valid = (key, value) => /^[1-9]$/.test(value || '') && (!enums[key] || Object.values(enums[key]).includes(value));
function restore(meta, defaults) {
const query = new URLSearchParams(meta.redirectUtm || '');
const digits = {};
for (const key of keys) {
let value = meta.utmDigits?.[key];
if (!valid(key, value)) {
// Только сохранённый record: старые enum/открытые цифры не являются raw URL alias.
const stored = query.get(legacy[key] || key) ?? query.get(key);
value = enums[key]?.[stored] || stored;
if (!valid(key, value)) value = Array.from(stored || '')[4];
}
digits[key] = valid(key, value) ? value : defaults[key];
}
return digits;
}
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function mask(digit) {
const bytes = new Uint8Array(10);
crypto.getRandomValues(bytes);
const chars = Array.from(bytes, value => alphabet[value % alphabet.length]);
chars[4] = digit;
return chars.join('');
}
function query(meta, defaults) {
const params = new URLSearchParams(meta.redirectUtm || '');
const digits = restore(meta, defaults);
for (const key of ['registration','bchoice','chb','key','shown_theme',...keys]) params.delete(key);
for (const key of keys) params.set(key, mask(digits[key]));
return params.toString();
}
window.ZoomzillaUtmHandoff = {restore, query};
})();
JS;
+14 -14
View File
@@ -17,10 +17,10 @@
},
"theme": {
"weights": {
"light": 0,
"dark": 1
"2": 0,
"1": 1
},
"default": "dark"
"default": "1"
},
"degr": {
"weights": {
@@ -29,19 +29,19 @@
},
"default": "1"
},
"registration": {
"reg": {
"weights": {
"before": 0,
"after": 1
"1": 0,
"2": 1
},
"default": "after"
"default": "2"
},
"bchoice": {
"chob": {
"weights": {
"a": 1,
"b": 0
"2": 1,
"1": 0
},
"default": "a"
"default": "2"
},
"tp": {
"weights": {
@@ -52,10 +52,10 @@
},
"focus": {
"weights": {
"center": 0,
"face": 1
"2": 0,
"1": 1
},
"default": "face"
"default": "1"
},
"cons": {
"weights": {
+8 -62
View File
@@ -4,7 +4,7 @@ require_once __DIR__ . '/i18n.php';
require_once __DIR__ . '/components/asset-url.php';
require_once __DIR__ . '/components/site-domain.php';
require_once __DIR__ . '/components/theme-preference.php';
require_once __DIR__ . '/components/cons-utm.php';
require_once __DIR__ . '/components/utm-codec.php';
$configFile = __DIR__ . '/config.json';
$config = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : [];
@@ -14,66 +14,12 @@ if (json_last_error() !== JSON_ERROR_NONE) {
}
Yii::init($config['default_language'] ?? 'ru');
$config = prelandNormalizeConsConfig($config);
function getWeightedRandom($weights)
{
if (empty($weights)) {
return '';
}
$totalWeight = array_sum($weights);
if ($totalWeight <= 0) {
$keys = array_keys($weights);
return (string)$keys[0];
}
$rand = (mt_rand() / mt_getrandmax()) * $totalWeight;
$current = 0.0;
foreach ($weights as $key => $weight) {
$current += (float)$weight;
if ($rand <= $current) {
return (string)$key;
}
}
$keys = array_keys($weights);
return (string)end($keys);
}
$get_params = prelandNormalizeConsQuery($_GET, $config['utm']['cons']);
if (isset($config['utm']) && is_array($config['utm'])) {
foreach ($get_params as $key => $value) {
if ($key === 'cons') continue;
if (!isset($config['utm'][$key])) {
continue;
}
if (isset($config['utm'][$key]['weights'][$value])) {
continue;
}
if ($value !== 'rnd') {
unset($get_params[$key]);
continue;
}
$weights = $config['utm'][$key]['weights'] ?? [];
$get_params[$key] = getWeightedRandom($weights);
}
foreach ($config['utm'] as $key => $value) {
if (isset($get_params[$key])) {
continue;
}
$get_params[$key] = $config['utm'][$key]['default'];
}
}
$UTM = $get_params;
$config = prelandNumericUtmConfig($config);
$resolvedUtm = prelandResolveUtm($_GET, $config);
$UTM = $resolvedUtm['context'];
$get_params = array_diff_key($_GET, array_fill_keys(['indpic','theme','degr','reg','chob','tp','cons','focus','registration','bchoice','chb','key'], true));
// Числа — внутренний snapshot для recovery. Публичная оболочка создаётся при навигации.
$redirect_digits = $resolvedUtm['digits'];
$themeInitial = prelandThemeResolve(($UTM['theme'] ?? 'light') === 'dark' ? 'dark' : 'light');
$themeSurface = 'preland';
$host = $config['host'] ?? ($_SERVER['HTTP_HOST'] ?? '');
@@ -104,7 +50,7 @@ $indpic = isset($UTM['indpic']) && preg_match('/^[a-zA-Z0-9_-]+$/', (string)$UTM
function renderComponent(string $name, array $params = []): void
{
global $assetsUriPrefix, $fontsUrl, $favicon, $logoAltExt, $spritePath, $UTM, $config, $indpic, $apiHost, $redirect_url_base, $redirect_url_utm;
global $redirect_digits, $assetsUriPrefix, $fontsUrl, $favicon, $logoAltExt, $spritePath, $UTM, $config, $indpic, $apiHost, $redirect_url_base, $redirect_url_utm;
extract($params, EXTR_SKIP);
$componentFile = __DIR__ . '/components/' . $name . '.php';
if (file_exists($componentFile)) {
+2 -2
View File
@@ -2,9 +2,9 @@
/** preland#17: consent-конфиг мигрируется после совместимого кода, без замены runtime-снимком. */
declare(strict_types=1);
$root=dirname(__DIR__);$workflow=file_get_contents($root.'/.gitea/workflows/deploy.yml');
$upload=strpos($workflow,'- name: Upload over SFTP');$patch=strpos($workflow,'- name: Migrate consent UTM in preserved host config');
$upload=strpos($workflow,'- name: Upload over SFTP');$patch=strpos($workflow,'- name: Migrate numeric UTM in preserved host config');
if($upload===false||$patch===false||$patch<$upload)throw new RuntimeException('Consent config patch должен выполняться после совместимого кода.');
$section=substr($workflow,$patch,strpos($workflow,'- name: Check the page',$patch)-$patch);
foreach(['get -p $REMOTE_DIR/config.json','ensure-cons-config.php','config.json.upload-$revision','rename $REMOTE_DIR/config.json.upload-$revision']as$expected){if(!str_contains($section,$expected))throw new RuntimeException('Отсутствует безопасный этап: '.$expected);}
foreach(['get -p $REMOTE_DIR/config.json','ensure-utm-config.php','config.json.upload-$revision','rename $REMOTE_DIR/config.json.upload-$revision']as$expected){if(!str_contains($section,$expected))throw new RuntimeException('Отсутствует безопасный этап: '.$expected);}
if(str_contains($section,'put -p config.json '))throw new RuntimeException('Снимок Git не должен заменять runtime config.');
echo "PASS: consent config patch follows compatible code and uses atomic targeted replacement\n";
+25
View File
@@ -0,0 +1,25 @@
<?php
/** api#326: точечная миграция должна сохранять варианты и типы чужих JSON-полей. */
declare(strict_types=1);
$script=dirname(__DIR__).'/bin/ensure-utm-config.php';
if(!is_file($script))throw new RuntimeException('Нет миграции числового runtime config');
$file=tempnam(sys_get_temp_dir(),'utm');
try{
$raw='{"host":"keep","empty":{},"list":[],"map":{"0":"a","1":"b"},"utm":{"registration":{"weights":{"before":3,"after":7},"default":"after"},"bchoice":{"weights":{"a":8,"b":2},"default":"b"},"theme":{"weights":{"light":7,"dark":3},"default":"light"},"focus":{"weights":{"center":6,"face":4},"default":"face"},"indpic":{"weights":{"1":9,"2":1},"default":"2"},"custom":{"x":{}}}}';
file_put_contents($file,$raw);exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file),$out,$code);
if($code!==10)throw new RuntimeException('Первый patch должен вернуть10');
$actual=json_decode(file_get_contents($file)); $before=json_decode($raw);
foreach(['host','empty','list','map'] as $key)if(serialize($actual->$key)!==serialize($before->$key))throw new RuntimeException('Изменён соседний JSON тип');
if(isset($actual->utm->registration,$actual->utm->bchoice))throw new RuntimeException('Остались старые имена');
if(json_encode($actual->utm->reg)!=='{"weights":{"1":3,"2":7},"default":"2"}'||json_encode($actual->utm->chob)!=='{"weights":{"2":8,"1":2},"default":"1"}')throw new RuntimeException('Изменён смысл reg/chob');
if($actual->utm->theme->default!=='2'||$actual->utm->focus->default!=='1'||serialize($actual->utm->custom)!==serialize($before->utm->custom))throw new RuntimeException('Неверная миграция enum');
$first=file_get_contents($file);exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file),$out,$code);
if($code!==0||file_get_contents($file)!==$first)throw new RuntimeException('Patch не идемпотентен');
file_put_contents($file,'{"utm":{"theme":{"weights":{"1":0,"2":1},"default":2}}}');
$numeric=file_get_contents($file);exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file),$out,$code);
if($code!==0||file_get_contents($file)!==$numeric)throw new RuntimeException('Уже числовой integer default должен сохраняться');
file_put_contents($file,'{"utm":{"registration":{"weights":{"before":1},"default":"before"},"reg":{"weights":{"1":1},"default":"1"}}}');
$conflict=file_get_contents($file);exec(PHP_BINARY.' '.escapeshellarg($script).' '.escapeshellarg($file).' 2>/dev/null',$out,$code);
if($code!==2||file_get_contents($file)!==$conflict)throw new RuntimeException('Неоднозначный config должен блокировать patch без записи');
echo "OK: numeric config migration preserves weights/default/foreign JSON types\n";
}finally{unlink($file);}
+28
View File
@@ -0,0 +1,28 @@
<?php
/** api#326: публичные маски выбирают внутренние варианты без открытых alias. */
declare(strict_types=1);
$_GET = ['indpic'=>'abcd2', 'theme'=>'abcd2x', 'degr'=>'abcd2', 'reg'=>'abcd1', 'chob'=>'abcd1', 'tp'=>'abcd2', 'cons'=>'abcd3', 'focus'=>'abcd2', 'registration'=>'before', 'bchoice'=>'b', 'chb'=>'a', 'utm_source'=>'abcd9same', 'subid'=>'тест + &'];
$_SERVER['HTTP_HOST']='zoomstage.online';
ob_start(); require dirname(__DIR__).'/index.php'; $html=ob_get_clean();
$expected=['indpic'=>'2','theme'=>'light','degr'=>'2','registration'=>'before','bchoice'=>'b','tp'=>'2','cons'=>'3','focus'=>'center'];
foreach($expected as $key=>$value) if(($UTM[$key]??null)!==$value) throw new RuntimeException("Неверный внутренний вариант $key");
if(($UTM['utm_source']??null)!=='abcd9same'||($UTM['subid']??null)!=='тест + &') throw new RuntimeException('Чужие значения изменены');
if(!function_exists('prelandResolveUtm')) throw new RuntimeException('Отсутствует единая граница UTM');
$config=['utm'=>['indpic'=>['weights'=>['1'=>0,'2'=>100],'default'=>'1'],'theme'=>['weights'=>['1'=>3,'2'=>7],'default'=>'1']]];
foreach(['2','rnd','dark','abcdx','abc',[],null,'abcd0'] as $value) {
$resolved=prelandResolveUtm(['theme'=>$value],$config);
if($value==='abcd0') continue;
if($resolved['digits']['theme']!=='1')throw new RuntimeException('Invalid theme обязан использовать default');
}
if(prelandResolveUtm(["theme"=>"абвг2"],$config)["digits"]["theme"]!=="2")throw new RuntimeException("Unicode fifth symbol");
for($i=0;$i<50;$i++) if(prelandResolveUtm(['indpic'=>'abcd0'],$config)['digits']['indpic']!=='2')throw new RuntimeException('0 нарушил веса');
$settings=json_decode(file_get_contents(dirname(__DIR__).'/config.json'),true);
$defaults=['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','chob'=>'2','tp'=>'2','cons'=>'2','focus'=>'1'];
foreach(array_keys($defaults) as $key) foreach(['1','rnd','abcdx','abc',[],null,"\xffabcd2"] as $invalid) {
$resolved=prelandResolveUtm([$key=>$invalid,'registration'=>'before','bchoice'=>'b','chb'=>'a'],$settings);
if($resolved['digits'][$key]!==$defaults[$key])throw new RuntimeException('Открытый/invalid URL изменил '.$key);
if(isset($resolved['context']['chb']))throw new RuntimeException('Legacy chb прошёл как foreign');
}
if(prelandResolveUtm(['tp'=>'abcd9'],$settings)['digits']['tp']!=='2')throw new RuntimeException('Ненастроенная группа tp должна fallback');
if(prelandResolveUtm(['cons'=>'abcd9'],$settings)['digits']['cons']!=='9')throw new RuntimeException('Cons concrete не зависит от random weights');
echo "OK: numeric public boundary, defaults, weighted0, foreign values\n";
+5 -9
View File
@@ -32,14 +32,6 @@ if (!is_string($query)) {
parse_str($query, $params);
$config = json_decode((string) file_get_contents(dirname(__DIR__) . '/config.json'), true);
$expected = [
'indpic' => '1',
'theme' => 'dark',
'degr' => '1',
'registration' => 'after',
'bchoice' => 'a',
'cons' => '2',
'tp' => $config['utm']['tp']['default'] ?? null,
'focus' => 'face',
'utm_source' => 'stage-ad',
'subid' => 'click-42',
];
@@ -48,10 +40,14 @@ ksort($params);
ksort($expected);
if ($params !== $expected) {
fwrite(STDERR, "FAIL: claim URL обязан нести все resolved UTM и внешние параметры.\n");
fwrite(STDERR, "FAIL: foreign query обязан сохранить внешние параметры; собственные цифры хранятся отдельно.\n");
exit(1);
}
if ($redirect_digits !== ['indpic'=>'1','theme'=>'1','degr'=>'1','reg'=>'2','chob'=>'2','tp'=>'2','cons'=>'2','focus'=>'1']) {
throw new RuntimeException('Type-in обязан сохранить полный resolved numeric snapshot для handoff/recovery.');
}
if (isset($params['key'])) {
fwrite(STDERR, "FAIL: внешний key не должен подменять одноразовый ключ загрузки.\n");
exit(1);
+7 -5
View File
@@ -22,12 +22,12 @@ if (!is_array($config)) {
}
$focus = $config['utm']['focus'] ?? null;
if (($focus['weights'] ?? null) !== ['center' => 0, 'face' => 1]
|| ($focus['default'] ?? null) !== 'face') {
if (($focus['weights'] ?? null) !== ['2' => 0, '1' => 1]
|| ($focus['default'] ?? null) !== '1') {
$failures[] = 'Рабочий config.json должен фиксировать явно согласованный focus=face.';
}
foreach (['indpic', 'theme', 'degr', 'registration', 'bchoice', 'cons', 'tp'] as $key) {
foreach (['indpic', 'theme', 'degr', 'reg', 'chob', 'cons', 'tp'] as $key) {
$setting = $config['utm'][$key] ?? null;
if (!is_array($setting) || !is_array($setting['weights'] ?? null) || !is_string($setting['default'] ?? null)) {
$failures[] = "Рабочий config.json должен хранить валидный stage-снимок UTM {$key}.";
@@ -39,8 +39,10 @@ $workflow = (string) file_get_contents($root . '/.gitea/workflows/deploy.yml');
$stageStart = strpos($workflow, ' deploy-zoomstage:');
$stageBlock = $stageStart === false ? '' : substr($workflow, $stageStart);
if ($stageBlock === '' || !str_contains($stageBlock, 'config\\.json$')
|| !str_contains($stageBlock, 'ensure-focus-config.php')
|| !str_contains($stageBlock, 'Preserve host config and add focus if absent')) {
|| !str_contains($stageBlock, 'ensure-utm-config.php')
|| !str_contains($stageBlock, 'Migrate numeric UTM in preserved host config')
|| str_contains($stageBlock, 'php bin/ensure-focus-config.php')
|| str_contains($stageBlock, 'php bin/ensure-cons-config.php')) {
$failures[] = 'Stage обязан сохранять хостовый config.json и согласованный focus-контракт.';
}
if (preg_match_all('/^ [a-z][a-z0-9-]*:/m', substr($workflow, strpos($workflow, 'jobs:')), $jobs) !== 1
+1 -1
View File
@@ -8,7 +8,7 @@ try{for(const width of [1440,768,390,320])for(const theme of ['light','dark'])fo
const ctx=await browser.newContext({viewport:{width,height:900},isMobile:width<768,hasTouch:width<768}),page=await ctx.newPage(),errors=[];
page.on('pageerror',e=>errors.push(e.message));
await page.route('**/*',route=>['GET','HEAD','OPTIONS'].includes(route.request().method())?route.continue():route.abort());
await page.goto(base+'/?theme='+theme+'&indpic='+indpic,{waitUntil:'networkidle'});
await page.goto(base+'/?theme=abcd'+(theme==='dark'?'1':'2')+'&indpic=abcd'+indpic,{waitUntil:'networkidle'});
await page.waitForFunction(()=>document.querySelector('#examples').dataset.examplesState==='ready');
assert.equal(await page.title(),'Улучшение изображения');assert(await page.locator('#dropzone-form').isVisible());
const footer=await page.locator('.footer').innerText();assert(!/Наши реквизиты|ИНН:|ОГРНИП:|Кардаполов|Иванов/.test(footer));assert.equal(await page.locator('.footer img[src*="payment.svg"] ').count(),0);assert(footer.includes(new URL(base).hostname));
+36
View File
@@ -0,0 +1,36 @@
// api#326: реальный handoff codec, включая сохранённые legacy records.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync('/app/components/utm-handoff.php','utf8').split("<<<'JS'\n")[1].split('\nJS;')[0];
const context = {window:{},URLSearchParams,Uint8Array,crypto:require('node:crypto').webcrypto};
vm.runInNewContext(source,context);
const handoff=context.window.ZoomzillaUtmHandoff;
const defaults={indpic:'1',theme:'1',degr:'1',reg:'2',chob:'2',tp:'1',cons:'2',focus:'1'};
const selected={indpic:'2',theme:'2',degr:'2',reg:'1',chob:'1',tp:'2',cons:'3',focus:'2'};
const foreign='utm_source=abcd9same&subid=%D1%82%D0%B5%D1%81%D1%82+%2B+%26&id=42&stage=keep';
for(const meta of [
{redirectUtm:foreign,utmDigits:selected},
{redirectUtm:foreign+'&indpic=2&theme=light&degr=2&registration=before&bchoice=b&tp=2&cons=3&focus=center&chb=a'},
{redirectUtm:foreign+'&indpic=abcd2&theme=абвг2&degr=abcd2&reg=abcd1&chob=abcd1&tp=abcd2&cons=abcd3&focus=abcd2'}
]){
const before=JSON.stringify(meta);
const first=new URLSearchParams(handoff.query(meta,defaults));
const second=new URLSearchParams(handoff.query(meta,defaults));
for(const [key,digit] of Object.entries(selected)){
assert.match(first.get(key),/^[A-Za-z0-9]{10}$/);
assert.equal(first.get(key)[4],digit);
assert.equal(second.get(key)[4],digit);
assert.notEqual(first.get(key),second.get(key));
}
for(const query of [first,second]){
assert.equal(query.get('utm_source'),'abcd9same');
assert.equal(query.get('subid'),'тест + &');
assert.equal(query.get('id'),'42'); assert.equal(query.get('stage'),'keep');
for(const legacy of ['registration','bchoice','chb'])assert.equal(query.has(legacy),false);
}
assert.equal(JSON.stringify(meta),before);
}
const typeIn=new URLSearchParams(handoff.query({redirectUtm:'',utmDigits:defaults},defaults));
for(const [key,digit]of Object.entries(defaults))assert.equal(typeIn.get(key)[4],digit);
console.log('OK: all8 fresh noise/repeated handoff, legacy recovery, type-in, foreign/technical values');
+1 -1
View File
@@ -2,5 +2,5 @@
if (PHP_SAPI !== 'cli') { http_response_code(404); exit; }
$_SERVER['HTTP_HOST'] = $argv[1] ?? 'zoomstage.online';
$_SERVER['SERVER_NAME'] = 'fixture.local';
$_GET = ['theme' => $argv[2] ?? 'light', 'indpic' => '1'];
$_GET = ['theme' => 'abcd' . (($argv[2] ?? 'light') === 'dark' ? '1' : '2'), 'indpic' => 'abcd1'];
require dirname(__DIR__) . '/index.php';