From 6bfcd20cc0ef900486cf0e02871f7e35371bfa02 Mon Sep 17 00:00:00 2001 From: clockdev <51827193+clockdev@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:02:32 +0300 Subject: [PATCH 1/2] Add bounded named query filters and URL macros --- src/Engine/Context.php | 8 ++ src/Engine/FilterCompiler.php | 4 + src/Engine/MacroExpander.php | 1 + tests/Integration/Engine/QueryParamsTest.php | 114 +++++++++++++++++++ tests/Unit/Engine/FilterCompilerTest.php | 65 +++++++++++ tests/Unit/Engine/MacroExpanderTest.php | 39 +++++++ 6 files changed, 231 insertions(+) create mode 100644 tests/Integration/Engine/QueryParamsTest.php diff --git a/src/Engine/Context.php b/src/Engine/Context.php index fd1e350..4df721c 100644 --- a/src/Engine/Context.php +++ b/src/Engine/Context.php @@ -51,4 +51,12 @@ public function __construct( public readonly string $campaignSlug, public readonly int $timestamp, // unix ) {} + + public function queryParam(string $name): ?string + { + if (!preg_match('/\A[A-Za-z_][A-Za-z0-9_-]{0,63}\z/', $name)) return null; + $value = $this->query[$name] ?? null; + if (!is_string($value) || strlen($value) > 2048 || preg_match('/[\x00-\x1F\x7F]/', $value)) return null; + return $value; + } } diff --git a/src/Engine/FilterCompiler.php b/src/Engine/FilterCompiler.php index 64f3d16..9bb6a2d 100644 --- a/src/Engine/FilterCompiler.php +++ b/src/Engine/FilterCompiler.php @@ -75,6 +75,10 @@ private function compileCondition(array $cond): Closure private static function fieldExtractor(string $field): Closure { + if (str_starts_with($field, 'param:')) { + $name = substr($field, 6); + return static fn (Context $c) => $c->queryParam($name); + } return match ($field) { 'country' => static fn (Context $c) => $c->country, 'region' => static fn (Context $c) => $c->region, diff --git a/src/Engine/MacroExpander.php b/src/Engine/MacroExpander.php index 2fd08f7..efe967f 100644 --- a/src/Engine/MacroExpander.php +++ b/src/Engine/MacroExpander.php @@ -18,6 +18,7 @@ public function expand(string $template, Context $ctx): string private function resolve(string $name, string $arg, Context $ctx): ?string { return match ($name) { + 'param' => rawurlencode($ctx->queryParam($arg) ?? ''), 'country' => $ctx->country, 'region' => $ctx->region, 'city' => $ctx->city, diff --git a/tests/Integration/Engine/QueryParamsTest.php b/tests/Integration/Engine/QueryParamsTest.php new file mode 100644 index 0000000..34ad385 --- /dev/null +++ b/tests/Integration/Engine/QueryParamsTest.php @@ -0,0 +1,114 @@ +exec('DELETE FROM stats.clicks'); + $pdo->exec('DELETE FROM stats.visitors_fingerprints'); + $pdo->exec('DELETE FROM core.flows'); + $pdo->exec('DELETE FROM core.offers'); + $pdo->exec('DELETE FROM core.campaigns'); + $this->db = new Connection($pdo); + $cRepo = new CampaignRepository($this->db, new CampaignIdGenerator()); + $oRepo = new OfferRepository($this->db); + $fRepo = new FlowRepository($this->db); + $compiler = new FilterCompiler(); + $this->handler = new ClickHandler( + $cRepo, $oRepo, + new VisitorResolver($this->db), + new DeviceDetector(), + new GeoLookup(), + new BotDetector($this->db), + new FlowMatcher($fRepo, $compiler), + new OfferPicker(), + new MacroExpander(), + new SchemaRegistry(), + $this->db, + new TelegramNotifier(null, null), + new SettingsRepository($this->db), + new NotificationRegistry(), + ); + + $this->campaign = $cRepo->create(['name' => 'Named query test', 'slug' => 'params01', 'is_active' => '1']); + $accept = $oRepo->create(['name' => 'Accept', 'url' => 'https://accept.example/?p={param:brand}&country={country}&query={param:country}', 'is_active' => '1']); + $fallback = $oRepo->create(['name' => 'Fallback', 'url' => 'https://fallback.example/?p={param:brand}', 'is_active' => '1']); + $this->acceptFlow = $fRepo->create($this->campaign->id, [ + 'name' => 'brand match', + 'filters' => [[['field' => 'param:brand', 'op' => 'eq', 'value' => 'ember']]], + 'target_type' => 'offers', + 'target_offers' => [['offer_id' => $accept->id, 'weight' => 100]], + 'schema_id' => 2, + 'is_active' => '1', + ]); + $fRepo->create($this->campaign->id, [ + 'name' => 'fallback', + 'filters' => [], + 'target_type' => 'offers', + 'target_offers' => [['offer_id' => $fallback->id, 'weight' => 100]], + 'schema_id' => 2, + 'is_active' => '1', + ]); +}); + +test('named query parameters route actual PSR requests to exact destinations', function (array $query, string $location): void { + // Campaign test links supply deterministic geo while retaining real filter evaluation. + $request = (new ServerRequestFactory())->createServerRequest('GET', '/params01') + ->withQueryParams($query + ['_t' => TestLink::campaignKey($this->campaign->id), '_geo' => 'ru']) + ->withHeader('User-Agent', 'Mozilla/5.0 TestBrowser'); + $response = $this->handler->handle($request, new Response(), 'params01'); + expect($response->getStatusCode())->toBe(302); + expect($response->getHeaderLine('Location'))->toBe($location); +})->with([ + 'accept' => [['brand' => 'ember', 'country' => 'us'], 'https://accept.example/?p=ember&country=ru&query=us'], + 'reject' => [['brand' => 'ash'], 'https://fallback.example/?p=ash'], + 'missing' => [[], 'https://fallback.example/?p='], + 'zero' => [['brand' => '0'], 'https://fallback.example/?p=0'], + 'empty' => [['brand' => ''], 'https://fallback.example/?p='], + 'array' => [['brand' => ['ember']], 'https://fallback.example/?p='], + 'null' => [['brand' => null], 'https://fallback.example/?p='], + 'control' => [['brand' => "ember\r\nLocation: bad"], 'https://fallback.example/?p='], + 'oversize' => [['brand' => str_repeat('x', 2049)], 'https://fallback.example/?p='], + 'encoded' => [['brand' => 'a &%/é%20'], 'https://fallback.example/?p=a%20%26%25%2F%C3%A9%2520'], +]); + +test('named query parameters route arbitrary field names and boundary values in the pipeline', function (string $name, string $value): void { + $this->db->execute( + 'UPDATE core.flows SET filters = :filters::jsonb WHERE id = :id', + ['filters' => json_encode([[['field' => 'param:' . $name, 'op' => 'eq', 'value' => $value]]], JSON_THROW_ON_ERROR), 'id' => $this->acceptFlow->id], + ); + $request = (new ServerRequestFactory())->createServerRequest('GET', '/params01') + ->withQueryParams([$name => $value, 'brand' => $value, '_t' => TestLink::campaignKey($this->campaign->id), '_geo' => 'ru']) + ->withHeader('User-Agent', 'Mozilla/5.0 TestBrowser'); + $response = $this->handler->handle($request, new Response(), 'params01'); + expect($response->getStatusCode())->toBe(302); + expect($response->getHeaderLine('Location'))->toBe('https://accept.example/?p=' . rawurlencode($value) . '&country=ru&query='); +})->with([ + 'slot 30' => ['sub_id_30', 'ember'], + 'no finite slot cap' => ['sub_id_31', 'ember'], + 'custom' => ['_custom-2', 'ember'], + '64 name' => [str_repeat('a', 64), 'ember'], + '2048 value' => ['brand', str_repeat('x', 2048)], +]); diff --git a/tests/Unit/Engine/FilterCompilerTest.php b/tests/Unit/Engine/FilterCompilerTest.php index 9407708..ae4213d 100644 --- a/tests/Unit/Engine/FilterCompilerTest.php +++ b/tests/Unit/Engine/FilterCompilerTest.php @@ -80,3 +80,68 @@ function ctx(array $overrides = []): Context { $b = $compiler->compile([[['field' => 'country', 'op' => 'eq', 'value' => 'ru']]]); expect($a)->toBe($b); }); + +test('named query parameters use existing operators for arbitrary names', function (string $name): void { + $compiler = new FilterCompiler(); + foreach (['eq' => true, 'neq' => false, 'in' => true, 'not_in' => false, 'exists' => true] as $op => $matches) { + $value = in_array($op, ['in', 'not_in'], true) ? ['ember', 'other'] : 'ember'; + $filter = $compiler->compile([[['field' => 'param:' . $name, 'op' => $op, 'value' => $value]]]); + expect($filter(ctx(['query' => [$name => 'ember']])))->toBe($matches); + expect($filter(ctx(['query' => [$name => 'reject']])))->toBe($op === 'exists' || !$matches); + expect($filter(ctx()))->toBe(in_array($op, ['neq', 'not_in'], true)); + } +})->with(['brand', 'sub_id_30', 'sub_id_31', '_custom-2', 'Campaign', str_repeat('a', 64)]); + +test('named query parameters retain empty and zero strings and maximum byte value', function (): void { + $compiler = new FilterCompiler(); + foreach (['', '0', str_repeat('x', 2048), str_repeat('é', 1024)] as $value) { + expect(ctx(['query' => ['brand' => $value]])->queryParam('brand'))->toBe($value); + $filter = $compiler->compile([[['field' => 'param:brand', 'op' => 'eq', 'value' => $value]]]); + expect($filter(ctx(['query' => ['brand' => $value]])))->toBeTrue(); + } + $exists = $compiler->compile([[['field' => 'param:brand', 'op' => 'exists', 'value' => null]]]); + expect($exists(ctx(['query' => ['brand' => '']])))->toBeFalse(); + expect($exists(ctx(['query' => ['brand' => '0']])))->toBeTrue(); + expect(ctx()->queryParam('brand'))->toBeNull(); +}); + +test('named query parameters treat invalid names and values as missing', function (string $name, mixed $value): void { + $compiler = new FilterCompiler(); + $context = ctx(['query' => [$name => $value]]); + expect($context->queryParam($name))->toBeNull(); + expect($context->query)->toBe([$name => $value]); + foreach (['eq' => false, 'neq' => true, 'in' => false, 'not_in' => true, 'exists' => false] as $op => $matches) { + $filter = $compiler->compile([[['field' => 'param:' . $name, 'op' => $op, 'value' => 'ember']]]); + expect($filter(ctx(['query' => [$name => $value]])))->toBe($matches); + } +})->with([ + 'empty name' => ['', 'ember'], + '65 character name' => [str_repeat('a', 65), 'ember'], + 'digit first' => ['1brand', 'ember'], + 'hyphen first' => ['-brand', 'ember'], + 'dot' => ['br.and', 'ember'], + 'colon' => ['brand:other', 'ember'], + 'unicode name' => ['бренд', 'ember'], + 'newline name' => ["brand\n", 'ember'], + 'array' => ['brand', ['ember']], + 'null' => ['brand', null], + 'integer' => ['brand', 0], + 'boolean' => ['brand', true], + '2049 bytes' => ['brand', str_repeat('x', 2049)], + 'multibyte oversize' => ['brand', str_repeat('é', 1025)], + ...array_combine( + array_map(fn (int $i) => 'control ' . $i, [...range(0, 31), 127]), + array_map(fn (int $i) => ['brand', 'ember' . chr($i)], [...range(0, 31), 127]), + ), +]); + +test('named query parameters preserve builtin fields and malformed prefixes stay unknown', function (): void { + $compiler = new FilterCompiler(); + $context = ctx(['country' => 'ru', 'query' => ['country' => 'us', 'brand' => 'ember']]); + foreach (['country' => 'ru', 'param:country' => 'us'] as $field => $value) { + expect(($compiler->compile([[['field' => $field, 'op' => 'eq', 'value' => $value]]]))($context))->toBeTrue(); + } + foreach (['param', 'Param:brand', 'parambrand', 'param::brand'] as $field) { + expect(($compiler->compile([[['field' => $field, 'op' => 'eq', 'value' => 'ember']]]))($context))->toBeFalse(); + } +}); diff --git a/tests/Unit/Engine/MacroExpanderTest.php b/tests/Unit/Engine/MacroExpanderTest.php index 513d82d..012cf74 100644 --- a/tests/Unit/Engine/MacroExpanderTest.php +++ b/tests/Unit/Engine/MacroExpanderTest.php @@ -75,3 +75,42 @@ } expect(array_keys($seen))->toEqualCanonicalizing(['a', 'b', 'c']); }); + +test('named query parameters encode URL components for arbitrary names', function (string $name): void { + $this->ctx->query[$name] = 'a &%/é%20'; + expect($this->expander->expand('https://example.com/?p={param:' . $name . '}', $this->ctx)) + ->toBe('https://example.com/?p=a%20%26%25%2F%C3%A9%2520'); +})->with(['brand', 'sub_id_30', 'sub_id_31', '_custom-2', 'Campaign', str_repeat('a', 64)]); + +test('named query parameters expand missing and invalid recognized tokens to empty', function (string $name, mixed $value): void { + $this->ctx->query[$name] = $value; + expect($this->expander->expand('p={param:' . $name . '}', $this->ctx))->toBe('p='); +})->with([ + 'empty name' => ['', 'ember'], + '65 character name' => [str_repeat('a', 65), 'ember'], + 'digit first' => ['1brand', 'ember'], + 'hyphen first' => ['-brand', 'ember'], + 'space' => ['br and', 'ember'], + 'colon' => ['brand:other', 'ember'], + 'unicode name' => ['бренд', 'ember'], + 'newline name' => ["brand\n", 'ember'], + 'array' => ['brand', ['ember']], + 'null' => ['brand', null], + 'integer' => ['brand', 0], + 'boolean' => ['brand', true], + '2049 bytes' => ['brand', str_repeat('x', 2049)], + 'multibyte oversize' => ['brand', str_repeat('é', 1025)], + ...array_combine( + array_map(fn (int $i) => 'control ' . $i, [...range(0, 31), 127]), + array_map(fn (int $i) => ['brand', 'ember' . chr($i)], [...range(0, 31), 127]), + ), +]); + +test('named query parameters preserve zero empty maximum values builtins and unknown policy', function (): void { + $this->ctx->query = ['country' => 'us', 'zero' => '0', 'empty' => '', 'max' => str_repeat('x', 2048)]; + expect($this->expander->expand('{country}/{param:country}/{param:zero}/{param:empty}/{param:missing}/{unknown}', $this->ctx)) + ->toBe('ru/us/0///{unknown}'); + expect($this->expander->expand('{param:max}', $this->ctx))->toBe(str_repeat('x', 2048)); + $this->ctx->query['max'] = str_repeat('é', 1024); + expect($this->expander->expand('{param:max}', $this->ctx))->toBe(str_repeat('%C3%A9', 1024)); +}); From 19cfdfd89011d201553432609eef79a3515fb6ee Mon Sep 17 00:00:00 2001 From: clockdev <51827193+clockdev@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:02:32 +0300 Subject: [PATCH 2/2] Add named query parameter controls to flow editor --- resources/translations/messages.en.yaml | 2 + resources/translations/messages.ru.yaml | 2 + resources/views/admin/flows/form.php | 13 +++- src/Admin/Form/FlowForm.php | 16 ++++- tests/Browser/QueryParamsBrowser.test.php | 63 ++++++++++++++++ .../Integration/Admin/FlowQueryParamsTest.php | 72 +++++++++++++++++++ tests/Unit/Admin/FlowFormTest.php | 24 +++++++ 7 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 tests/Browser/QueryParamsBrowser.test.php create mode 100644 tests/Integration/Admin/FlowQueryParamsTest.php create mode 100644 tests/Unit/Admin/FlowFormTest.php diff --git a/resources/translations/messages.en.yaml b/resources/translations/messages.en.yaml index b3fb139..a5400f1 100644 --- a/resources/translations/messages.en.yaml +++ b/resources/translations/messages.en.yaml @@ -192,6 +192,7 @@ offers: empty_hint: 'Create your first offer — a URL with {click_id}/{country}/{payout} macros, plus a default payout.' flows: + parameter_name: Parameter name title: Flows list_title: 'Flows: {campaign}' create: Create flow @@ -262,6 +263,7 @@ schemas: 16: JS Redirect filter_fields: + query_parameter: Query parameter country: Country region: Region city: City diff --git a/resources/translations/messages.ru.yaml b/resources/translations/messages.ru.yaml index b54e716..50bfb5d 100644 --- a/resources/translations/messages.ru.yaml +++ b/resources/translations/messages.ru.yaml @@ -204,6 +204,7 @@ offers: empty_hint: 'Создайте первый офер — URL с макросами {click_id}/{country}/{payout}, и payout по умолчанию.' flows: + parameter_name: Имя параметра title: Потоки list_title: 'Потоки: {campaign}' create: Создать поток @@ -276,6 +277,7 @@ schemas: 16: JS Redirect filter_fields: + query_parameter: Параметр запроса country: Страна region: Регион city: Город diff --git a/resources/views/admin/flows/form.php b/resources/views/admin/flows/form.php index 8da81ad..570ae82 100644 --- a/resources/views/admin/flows/form.php +++ b/resources/views/admin/flows/form.php @@ -10,7 +10,7 @@ : url('/admin/campaigns/' . $campaign->id . '/flows/' . $flow->id); $titleKey = $flow === null ? 'flows.create' : 'flows.edit'; -$initialFilters = $flow?->filters ?? []; +$initialFilters = json_decode(old('filters', json_encode($flow?->filters ?? [])), true) ?? []; $initialTargetOffers = $flow?->targetOffers ?? []; $initialSchemaConfig = $flow?->schemaConfig ?? []; @@ -143,6 +143,7 @@
= e(t('flows.no_filters')) ?>
+= e(t($errors['filters'])) ?>