diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 840061b..243f091 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -129,6 +129,32 @@ 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 + env: + SFTP_HOST: ${{ secrets.ZOOMPIXEL_SFTP_HOST }} + SFTP_USER: ${{ secrets.ZOOMPIXEL_SFTP_USER }} + SFTP_PASS: ${{ secrets.ZOOMPIXEL_SFTP_PASS }} + SFTP_KNOWN_HOSTS: ${{ secrets.ZOOMPIXEL_SFTP_KNOWN_HOSTS }} + run: | + set -eu + umask 077 + known_hosts=$(mktemp); batch=$(mktemp); config=$(mktemp) + trap 'rm -f "$known_hosts" "$batch" "$config"' EXIT + printf '%s\n' "$SFTP_KNOWN_HOSTS" > "$known_hosts" + printf '%s\n' "get -p $REMOTE_DIR/config.json $config" > "$batch" + SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST" + changed=0 + php bin/ensure-cons-config.php "$config" || changed=$? + case "$changed" in + 0) ;; + 10) revision=$(git rev-parse HEAD) + printf '%s\n' "put -p $config $REMOTE_DIR/config.json.upload-$revision" "rename $REMOTE_DIR/config.json.upload-$revision $REMOTE_DIR/config.json" > "$batch" + SSHPASS="$SFTP_PASS" sshpass -e sftp -oBatchMode=no -oStrictHostKeyChecking=yes -oUserKnownHostsFile="$known_hosts" -b "$batch" "$SFTP_USER@$SFTP_HOST" ;; + *) exit "$changed" ;; + esac + - name: Check the page run: | set -eu diff --git a/README.md b/README.md index 88a5307..d1bc6ba 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Preland всегда формирует полный resolved-набор сво | `degr` | пресет ухудшения превью «До» | сайт (#42) | | `registration` | `before` — PIN до оплаты, `after` — после | сайт, страница результата | | `bchoice` | попап выбора банка (`a` — показать) | сайт, оплата (#44) | -| `chb` | галочки согласия на странице оплаты (`no` — не обязательны) | сайт, оплата | +| `cons` | номер группы согласий на Join; `rnd` выбирает группу по весам, исходно 1 с вероятностью 100%, default — 2 | сайт, оплата | | `tp` | группа тарифов | сайт, cpanel «Группы тарифов (tp)» | | `focus` | первоначальная область лупы Join: `center`/`face` | сайт, cpanel после `tp` | diff --git a/bin/ensure-cons-config.php b/bin/ensure-cons-config.php new file mode 100644 index 0000000..c03c584 --- /dev/null +++ b/bin/ensure-cons-config.php @@ -0,0 +1,36 @@ +utm ?? null) instanceof stdClass) throw new RuntimeException('В config.json отсутствует объект utm.'); + $hasCons = property_exists($config->utm, 'cons'); + $cons = $hasCons ? $config->utm->cons : (object) ['weights' => (object) ['1' => 100, '2' => 0], 'default' => '2']; + if (!$cons instanceof stdClass || !(is_array($cons->weights ?? null) || ($cons->weights ?? null) instanceof stdClass) + || !(is_string($cons->default ?? null) || is_int($cons->default ?? null)) + || !preg_match('/^[1-9][0-9]*$/D', (string) $cons->default) + || (float) $cons->default > 2147483647) { + throw new RuntimeException('Существующий utm.cons некорректен; автоматическая перезапись запрещена.'); + } + if (!property_exists($config->utm, 'chb') && $hasCons) { + fwrite(STDOUT, "cons уже настроен: config не изменён.\n"); + exit(0); + } + unset($config->utm->chb); + $config->utm->cons = $cons; + $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.json.'); + fwrite(STDOUT, "Мигрированы только utm.chb/cons; остальные поля и существующие настройки cons сохранены.\n"); + exit(10); +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage() . "\n"); + exit(2); +} diff --git a/components/cons-utm.php b/components/cons-utm.php new file mode 100644 index 0000000..f157b54 --- /dev/null +++ b/components/cons-utm.php @@ -0,0 +1,48 @@ + ['1' => 100, '2' => 0], 'default' => '2']; + } + return $config; +} + +/** Concrete номера не ограничены random-весами: новые группы создаются в CPanel API. */ +function prelandResolveCons(mixed $value, array $settings): string +{ + $valid = static fn (mixed $candidate): bool => (is_string($candidate) || is_int($candidate)) + && preg_match('/^[1-9][0-9]{0,9}$/D', (string) $candidate) === 1 + && (float) $candidate <= 2147483647; + $default = $valid($settings['default'] ?? null) ? (string) $settings['default'] : '2'; + if ($valid($value)) return (string) $value; + if ($value !== 'rnd') return $default; + + $weights = []; + foreach ((array) ($settings['weights'] ?? []) as $number => $weight) { + if ($valid($number) && is_numeric($weight) && is_finite((float) $weight) && (float) $weight > 0) { + $weights[(string) $number] = (float) $weight; + } + } + $total = array_sum($weights); + if ($total <= 0 || !is_finite($total)) return $default; + $point = (mt_rand() / (mt_getrandmax() + 1.0)) * $total; + foreach ($weights as $number => $weight) { + if ($point < $weight) return (string) $number; + $point -= $weight; + } + return (string) array_key_last($weights); +} + +/** Legacy chb не должен проходить как неизвестный внешний рекламный параметр. */ +function prelandNormalizeConsQuery(array $query, array $settings): array +{ + unset($query['chb']); + $query['cons'] = prelandResolveCons($query['cons'] ?? null, $settings); + return $query; +} diff --git a/components/form.php b/components/form.php index a1fd436..9adedbd 100644 --- a/components/form.php +++ b/components/form.php @@ -72,6 +72,16 @@ $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 createApiUrl = ; const uploadPolicyApiUrl = ; @@ -202,7 +212,8 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a function redirectToTour(record) { let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key); - if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm; + const redirectQuery = normalizeConsentRedirect(record.meta.redirectUtm); + if (redirectQuery) targetUrl += '&' + redirectQuery; const shownTheme = window.ZoomzillaTheme.current(); targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme); window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; }); diff --git a/config.json b/config.json index 24014ae..2576e16 100644 --- a/config.json +++ b/config.json @@ -39,12 +39,12 @@ }, "default": "a" }, - "chb": { + "cons": { "weights": { - "a": 1, - "b": 0 + "1": 100, + "2": 0 }, - "default": "b" + "default": "2" }, "tp": { "weights": { diff --git a/index.php b/index.php index 3c7504e..ff848cd 100644 --- a/index.php +++ b/index.php @@ -4,6 +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'; $configFile = __DIR__ . '/config.json'; $config = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : []; @@ -13,6 +14,7 @@ if (json_last_error() !== JSON_ERROR_NONE) { } Yii::init($config['default_language'] ?? 'ru'); +$config = prelandNormalizeConsConfig($config); function getWeightedRandom($weights) { @@ -40,10 +42,11 @@ function getWeightedRandom($weights) return (string)end($keys); } -$get_params = $_GET; +$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; } diff --git a/tests/check-cons-config-patch.php b/tests/check-cons-config-patch.php new file mode 100644 index 0000000..640b42c --- /dev/null +++ b/tests/check-cons-config-patch.php @@ -0,0 +1,33 @@ +'keep.example','requisites'=>['Не менять'],'utm'=>['theme'=>['default'=>'dark','weights'=>['light'=>7]],'chb'=>['default'=>'b','weights'=>['a'=>1,'b'=>0]],'focus'=>['default'=>'face']]]; +$path=tempnam(sys_get_temp_dir(),'cons-config-'); +try{ + file_put_contents($path,json_encode($fixture,JSON_THROW_ON_ERROR)); + exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path),$output,$code); + $actual=json_decode(file_get_contents($path),true,512,JSON_THROW_ON_ERROR); + if($code!==10||isset($actual['utm']['chb'])||($actual['utm']['cons']??null)!==['weights'=>['1'=>100,'2'=>0],'default'=>'2'])throw new RuntimeException('Patch должен заменить только согласованный ключ chb на cons.'); + $comparison=$actual;unset($comparison['utm']['cons']);$old=$fixture;unset($old['utm']['chb']); + if($comparison!==$old)throw new RuntimeException('Patch изменил соседние поля runtime config.'); + $before=file_get_contents($path);exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path),$output,$code); + if($code!==0||file_get_contents($path)!==$before)throw new RuntimeException('Повторный patch должен оставить байты без изменений.'); + $custom=$actual;$custom['utm']['cons']=['weights'=>['1'=>20,'3'=>80],'default'=>'3']; + file_put_contents($path,json_encode($custom,JSON_THROW_ON_ERROR));$before=file_get_contents($path); + exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path),$output,$code); + if($code!==0||file_get_contents($path)!==$before)throw new RuntimeException('Следующий deploy не должен сбросить настроенный владельцем cons.'); + foreach([[],true,1.5,'2147483648','0']as$invalid){ + $broken=$custom;$broken['utm']['cons']['default']=$invalid; + file_put_contents($path,json_encode($broken,JSON_THROW_ON_ERROR));$before=file_get_contents($path); + exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path).' 2>/dev/null',$output,$code); + if($code!==2||file_get_contents($path)!==$before)throw new RuntimeException('Некорректный существующий cons должен остановить deploy без перезаписи config.'); + } + $objectFixture='{"host":"keep","extra":{},"map":{"0":"a","1":"b"},"list":[],"utm":{"chb":{"default":"a"},"other":{"weights":{}}}}'; + file_put_contents($path,$objectFixture);exec(escapeshellarg(PHP_BINARY).' '.escapeshellarg($script).' '.escapeshellarg($path),$output,$code); + $beforeObject=json_decode($objectFixture,false,512,JSON_THROW_ON_ERROR);$afterObject=json_decode(file_get_contents($path),false,512,JSON_THROW_ON_ERROR); + unset($beforeObject->utm->chb,$afterObject->utm->cons); + if($code!==10||json_encode($beforeObject)!==json_encode($afterObject))throw new RuntimeException('Patch должен сохранять типы соседних пустых и numeric-key JSON объектов.'); + echo "PASS: cons config migration preserves other fields and JSON types, idempotent\n"; +}finally{@unlink($path);} diff --git a/tests/check-cons-delivery.php b/tests/check-cons-delivery.php new file mode 100644 index 0000000..15b9630 --- /dev/null +++ b/tests/check-cons-delivery.php @@ -0,0 +1,10 @@ +'api.example.test','utm'=>['theme'=>['default'=>'dark','weights'=>['dark'=>3]],'chb'=>['default'=>'a','weights'=>['a'=>1]]]]; +$normalized = prelandNormalizeConsConfig($fixture); +$expect(!isset($normalized['utm']['chb']), 'Старый chb должен быть удалён из runtime-копии конфига.'); +$expect($normalized['host'] === $fixture['host'] && $normalized['utm']['theme'] === $fixture['utm']['theme'], 'Соседние поля должны сохраняться.'); +$expect($fixture['utm']['chb']['default'] === 'a', 'Нормализация не должна менять исходный массив.'); +$settings = $normalized['utm']['cons']; +$expect(prelandResolveCons(null, $settings) === '2', 'Отсутствующее значение должно получить default2.'); +foreach (['1','2','3','99'] as $value) $expect(prelandResolveCons($value, $settings) === $value, 'Concrete cons должен приниматься независимо от весов: '.$value); +foreach (['0','-1','a','1x','',[],['1']] as $value) $expect(prelandResolveCons($value, $settings) === '2', 'Неверное значение должно получить default2.'); +for ($i=0; $i<1000; $i++) $expect(prelandResolveCons('rnd', $settings) === '1', 'rnd обязан выбрать только группу1.'); +$settings = ['default'=>'2','weights'=>['2'=>0,'1'=>100]]; +for ($i=0; $i<1000; $i++) $expect(prelandResolveCons('rnd', $settings) === '1', 'Нулевой вес не должен случайно попасть в выборку.'); +$query = prelandNormalizeConsQuery(['chb'=>'a','cons'=>'3','utm_source'=>'ad','subid'=>'42'], $settings); +$expect($query === ['cons'=>'3','utm_source'=>'ad','subid'=>'42'], 'Query должен удалить только chb и нормализовать cons.'); +$query = prelandNormalizeConsQuery(['chb'=>['b'],'utm_source'=>'ad'], $settings); +$expect(!isset($query['chb']) && $query['cons'] === '2' && $query['utm_source'] === 'ad', 'Array legacy chb не должен влиять на fallback.'); +echo "PASS: cons resolver {$checks} checks\n"; diff --git a/tests/check-utm-redirect-contract.php b/tests/check-utm-redirect-contract.php index a136f83..2393532 100644 --- a/tests/check-utm-redirect-contract.php +++ b/tests/check-utm-redirect-contract.php @@ -37,7 +37,7 @@ $expected = [ 'degr' => '1', 'registration' => 'after', 'bchoice' => 'a', - 'chb' => 'b', + 'cons' => '2', 'tp' => $config['utm']['tp']['default'] ?? null, 'focus' => 'face', 'utm_source' => 'stage-ad', diff --git a/tests/check-versioned-stage-config.php b/tests/check-versioned-stage-config.php index 2a3414c..708b081 100644 --- a/tests/check-versioned-stage-config.php +++ b/tests/check-versioned-stage-config.php @@ -27,7 +27,7 @@ if (!is_array($config)) { $failures[] = 'Рабочий config.json должен фиксировать явно согласованный focus=face.'; } - foreach (['indpic', 'theme', 'degr', 'registration', 'bchoice', 'chb', 'tp'] as $key) { + foreach (['indpic', 'theme', 'degr', 'registration', 'bchoice', '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}.";