diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 243f091..cc153ea 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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" diff --git a/README.md b/README.md index d1bc6ba..5af7a93 100644 --- a/README.md +++ b/README.md @@ -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°r=2®istration=after&bchoice=a&tp=1&utm_source=test&subid=CLICK1`. +`https://zoomstage.online/?indpic=e9st1mm12b&theme=e9st1mm12b°r=e9st2mm12b®=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:///api/images/public/claim?key=x' # 302 на https:/// — точка передачи на сайте есть ``` - В браузере: открыть `https://<домен>/?indpic=1&theme=dark&subid=TEST`, выбрать фото — адрес сменится на - `https:///public/upscale?id=…&indpic=1&theme=dark&subid=TEST…` (без `key=`), пойдёт обработка. + В браузере: открыть `https://<домен>/?indpic=abcd1efghi&theme=abcd1efghi&subid=TEST`, выбрать фото — адрес сменится на + `https:///public/upscale?id=…&indpic=<новый шум с1>&theme=<новый шум с1>&subid=TEST…` (без `key=`), пойдёт обработка. Это настоящая загрузка и обработка на сайте. ## Автодеплой (Gitea Actions) diff --git a/bin/ensure-utm-config.php b/bin/ensure-utm-config.php new file mode 100644 index 0000000..c9ca464 --- /dev/null +++ b/bin/ensure-utm-config.php @@ -0,0 +1,34 @@ +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); } diff --git a/components/form.php b/components/form.php index 63be154..2bf54fd 100644 --- a/components/form.php +++ b/components/form.php @@ -3,9 +3,9 @@ require_once __DIR__ . '/browser-upload.php'; require __DIR__ . '/upload-error.php'; echo ''; echo ''; +echo ''; $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 = ; const redirectUrlBase = ; const redirectUrlUtm = ; - // Сохранённая до перехода на 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 = ; const createApiUrl = ; const uploadPolicyApiUrl = ; @@ -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); diff --git a/components/utm-codec.php b/components/utm-codec.php new file mode 100644 index 0000000..f517e89 --- /dev/null +++ b/components/utm-codec.php @@ -0,0 +1,72 @@ +['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]; +} diff --git a/components/utm-handoff.php b/components/utm-handoff.php new file mode 100644 index 0000000..7812fc8 --- /dev/null +++ b/components/utm-handoff.php @@ -0,0 +1,41 @@ + /^[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; diff --git a/config.json b/config.json index 25b364c..bdf724d 100644 --- a/config.json +++ b/config.json @@ -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": { diff --git a/index.php b/index.php index ff848cd..7275fe7 100644 --- a/index.php +++ b/index.php @@ -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)) { diff --git a/tests/check-cons-delivery.php b/tests/check-cons-delivery.php index 15b9630..8163043 100644 --- a/tests/check-cons-delivery.php +++ b/tests/check-cons-delivery.php @@ -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"; diff --git a/tests/check-numeric-config-patch.php b/tests/check-numeric-config-patch.php new file mode 100644 index 0000000..c8c480e --- /dev/null +++ b/tests/check-numeric-config-patch.php @@ -0,0 +1,25 @@ +$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);} diff --git a/tests/check-numeric-utm.php b/tests/check-numeric-utm.php new file mode 100644 index 0000000..22f1093 --- /dev/null +++ b/tests/check-numeric-utm.php @@ -0,0 +1,28 @@ +'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"; diff --git a/tests/check-utm-redirect-contract.php b/tests/check-utm-redirect-contract.php index 2393532..05841b6 100644 --- a/tests/check-utm-redirect-contract.php +++ b/tests/check-utm-redirect-contract.php @@ -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); diff --git a/tests/check-versioned-stage-config.php b/tests/check-versioned-stage-config.php index 708b081..8cb4db5 100644 --- a/tests/check-versioned-stage-config.php +++ b/tests/check-versioned-stage-config.php @@ -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 diff --git a/tests/e2e/brand-footer.cjs b/tests/e2e/brand-footer.cjs index 519e7a7..e3416e9 100644 --- a/tests/e2e/brand-footer.cjs +++ b/tests/e2e/brand-footer.cjs @@ -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)); diff --git a/tests/e2e/utm-handoff.cjs b/tests/e2e/utm-handoff.cjs new file mode 100644 index 0000000..f13a51a --- /dev/null +++ b/tests/e2e/utm-handoff.cjs @@ -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°r=2®istration=before&bchoice=b&tp=2&cons=3&focus=center&chb=a'}, + {redirectUtm:foreign+'&indpic=abcd2&theme=абвг2°r=abcd2®=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'); diff --git a/tests/render-preland.php b/tests/render-preland.php index aee449a..a29d589 100644 --- a/tests/render-preland.php +++ b/tests/render-preland.php @@ -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';