Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions resources/translations/messages.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -262,6 +263,7 @@ schemas:
16: JS Redirect

filter_fields:
query_parameter: Query parameter
country: Country
region: Region
city: City
Expand Down
2 changes: 2 additions & 0 deletions resources/translations/messages.ru.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ offers:
empty_hint: 'Создайте первый офер — URL с макросами {click_id}/{country}/{payout}, и payout по умолчанию.'

flows:
parameter_name: Имя параметра
title: Потоки
list_title: 'Потоки: {campaign}'
create: Создать поток
Expand Down Expand Up @@ -276,6 +277,7 @@ schemas:
16: JS Redirect

filter_fields:
query_parameter: Параметр запроса
country: Страна
region: Регион
city: Город
Expand Down
13 changes: 10 additions & 3 deletions resources/views/admin/flows/form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [];

Expand Down Expand Up @@ -143,6 +143,7 @@
<div class="form-section">
<span class="form-section-label"><?= e(t('flows.filters')) ?></span>
<p style="font-size:0.8rem;color:var(--color-stone-400);font-family:var(--font-sans);margin-bottom:12px"><?= e(t('flows.no_filters')) ?></p>
<?php if (isset($errors['filters'])): ?><p class="form-error"><?= e(t($errors['filters'])) ?></p><?php endif; ?>

<template x-for="(group, gi) in filters" :key="gi">
<div style="border:1px solid var(--color-stone-200);border-radius:4px;padding:12px;margin-bottom:10px;background:var(--color-stone-50)">
Expand All @@ -152,12 +153,18 @@
style="font-size:0.8rem;color:var(--color-stone-400);background:none;border:none;cursor:pointer;padding:0 4px">&times;</button>
</div>
<template x-for="(cond, ci) in group" :key="ci">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<select x-model="cond.field" class="input" style="max-width:180px;font-size:0.8rem">
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:8px">
<select :value="cond.field.startsWith('param:') ? 'param:' : cond.field" @change="cond.field = $event.target.value" data-filter-field class="input" style="max-width:180px;font-size:0.8rem">
<?php foreach ($filterFields as $ff): ?>
<option value="<?= e($ff) ?>"><?= e(t('filter_fields.' . $ff)) ?></option>
<?php endforeach; ?>
<option value="param:"><?= e(t('filter_fields.query_parameter')) ?></option>
</select>
<template x-if="cond.field.startsWith('param:')">
<input :value="cond.field.slice(6)" @input="cond.field = 'param:' + $event.target.value"
data-param-name aria-label="<?= e(t('flows.parameter_name')) ?>" placeholder="<?= e(t('flows.parameter_name')) ?>"
class="input" style="max-width:180px;font-size:0.8rem;font-family:var(--font-mono)" autocomplete="off">
</template>
<select x-model="cond.op" class="input" style="max-width:100px;font-size:0.8rem">
<?php foreach ($filterOps as $op): ?>
<option value="<?= e($op) ?>"><?= e(t('filter_ops.' . $op)) ?></option>
Expand Down
16 changes: 15 additions & 1 deletion src/Admin/Form/FlowForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

final class FlowForm
{
/** @param array<string,mixed> $data @return array<string,string> */
/**
* @param array<string,mixed> $data
* @return array<string,string>
*/
public function validate(array $data): array
{
$errors = [];
Expand All @@ -31,6 +34,17 @@ public function validate(array $data): array
$filters = json_decode($filtersRaw, true);
if (!is_array($filters)) {
$errors['filters'] = 'validation.pattern';
} else {
foreach ($filters as $group) {
if (!is_array($group)) continue;
foreach ($group as $condition) {
$field = is_array($condition) ? ($condition['field'] ?? '') : '';
if (is_string($field) && str_starts_with($field, 'param:')
&& !preg_match('/\A[A-Za-z_][A-Za-z0-9_-]{0,63}\z/', substr($field, 6))) {
$errors['filters'] = 'validation.pattern';
}
}
}
}

// target_offers JSON must decode
Expand Down
8 changes: 8 additions & 0 deletions src/Engine/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
4 changes: 4 additions & 0 deletions src/Engine/FilterCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/Engine/MacroExpander.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
63 changes: 63 additions & 0 deletions tests/Browser/QueryParamsBrowser.test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

if (getenv('BROWSER_TESTS') !== '1') {
test('query editor browser tests skipped', function () {})->skip('set BROWSER_TESTS=1');
return;
}

beforeEach(function (): void {
browserAdmin('query-editor-pass');
$pdo = browserPdo();
$pdo->exec("DELETE FROM core.campaigns WHERE slug = 'query-browser'");
$pdo->exec("INSERT INTO core.campaigns (name, slug) VALUES ('Ember', 'query-browser')");
$this->cid = $pdo->query("SELECT id FROM core.campaigns WHERE slug = 'query-browser'")->fetchColumn();
});

afterEach(function (): void {
browserPdo()->exec("DELETE FROM core.campaigns WHERE slug = 'query-browser'");
});

test('named query editor saves reopens and switches existing country in RU and EN', function (string $lang, string $name, string $label, string $nameLabel): void {
$page = visit(browserUrl('/admin/login'), ['locale' => $lang === 'en' ? 'en-US' : 'ru-RU']);
$page->fill('login', 'admin')->fill('password', 'query-editor-pass')->press('button[type=submit]');
$page->navigate(browserUrl('/admin/campaigns/' . $this->cid . '/flows/new?lang=' . $lang));
expect($page->script('Alpine.$data(document.querySelector("[x-data^=flowBuilder]")).filters'))->toBe([]);
expect($page->script('document.querySelector("[name=name]").validity.valueMissing'))->toBeTrue();
expect($page->script('document.querySelector("button[type=submit]").disabled'))->toBeFalse();
$page->fill('name', 'Ember')->select('target_type', 'none');
$page->click('button[\@click="addGroup()"]');
$page->select('select[data-filter-field]', 'param:');
$page->assertVisible('select[data-filter-field]')->assertVisible('input[data-param-name]');
expect($page->script('document.querySelector("select[data-filter-field]").selectedOptions[0].textContent'))->toBe($label);
expect($page->script('document.querySelector("input[data-param-name]").getAttribute("aria-label")'))->toBe($nameLabel);
$page->fill('input[data-param-name]', $name)->select('select[x-model="cond.op"]', 'eq')->fill('input[x-model="cond.value"]', 'ember');
$page->press('button[type=submit]');
$pdo = browserPdo();
$stmt = $pdo->prepare('SELECT id, filters FROM core.flows WHERE campaign_id = ?');
$stmt->execute([$this->cid]);
$flow = $stmt->fetch(PDO::FETCH_ASSOC);
expect(json_decode($flow['filters'], true))->toEqual([[['field' => 'param:' . $name, 'op' => 'eq', 'value' => 'ember']]]);
$edit = '/admin/campaigns/' . $this->cid . '/flows/' . $flow['id'] . '/edit?lang=' . $lang;
$page->navigate(browserUrl($edit))->assertValue('select[data-filter-field]', 'param:')->assertValue('input[data-param-name]', $name)
->assertValue('select[x-model="cond.op"]', 'eq')->assertValue('input[x-model="cond.value"]', 'ember');
$page->select('select[data-filter-field]', 'country')->fill('input[x-model="cond.value"]', 'us')->press('button[type=submit]');
$page->navigate(browserUrl($edit))->assertValue('select[data-filter-field]', 'country')->assertValue('input[x-model="cond.value"]', 'us');
$stmt->execute([$this->cid]);
expect(json_decode($stmt->fetch(PDO::FETCH_ASSOC)['filters'], true))->toEqual([[['field' => 'country', 'op' => 'eq', 'value' => 'us']]]);
})->with([['en', 'brand', 'Query parameter', 'Parameter name'], ['ru', 'sub_id_30', 'Параметр запроса', 'Имя параметра']]);

test('invalid query name is retained safely after server rejection', function (): void {
$page = visit(browserUrl('/admin/login'), ['locale' => 'en-US']);
$page->fill('login', 'admin')->fill('password', 'query-editor-pass')->press('button[type=submit]');
$page->navigate(browserUrl('/admin/campaigns/' . $this->cid . '/flows/new?lang=en'))->fill('name', 'Ember')->select('target_type', 'none');
$page->click('button[\@click="addGroup()"]')->select('select[data-filter-field]', 'param:');
$name = '<img src=x onerror=alert(1)>';
$page->fill('input[data-param-name]', $name)->select('select[x-model="cond.op"]', 'neq')->fill('input[x-model="cond.value"]', 'cinder')->press('button[type=submit]');
$page->assertValue('input[data-param-name]', $name)->assertValue('select[x-model="cond.op"]', 'neq')->assertValue('input[x-model="cond.value"]', 'cinder')->assertVisible('.form-error');
expect($page->script('document.querySelectorAll("img[src=x]").length'))->toBe(0);
$stmt = browserPdo()->prepare('SELECT count(*) FROM core.flows WHERE campaign_id = ?');
$stmt->execute([$this->cid]);
expect((int)$stmt->fetchColumn())->toBe(0);
});
72 changes: 72 additions & 0 deletions tests/Integration/Admin/FlowQueryParamsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

use App\Admin\Controller\FlowController;
use App\Admin\Form\FlowForm;
use App\Admin\Repository\CampaignRepository;
use App\Admin\Repository\FlowRepository;
use App\Admin\Repository\OfferRepository;
use App\Shared\Asset\Manifest;
use App\Shared\CampaignIdGenerator;
use App\Shared\Db\Connection;
use App\Shared\I18n\I18n;
use App\Shared\I18n\TranslatorFactory;
use App\Shared\View\View;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;

beforeEach(function (): void {
$_SESSION = [];
$GLOBALS['__old_bag__'] = [];
$this->pdo = pdo();
$this->pdo->beginTransaction();
$db = new Connection($this->pdo);
$campaigns = new CampaignRepository($db, new CampaignIdGenerator());
$this->campaign = $campaigns->create(['name' => 'Ember', 'slug' => 'query-editor']);
$this->flows = new FlowRepository($db);
$this->controller = new FlowController($this->flows, $campaigns, new OfferRepository($db), new FlowForm());
$root = dirname(__DIR__, 3);
$this->view = new View($root . '/resources/views', new Manifest($root . '/public/assets/manifest.json'), new I18n((new TranslatorFactory($root . '/resources/translations'))->create()));
});

afterEach(function (): void {
$this->pdo->rollBack();
$GLOBALS['__old_bag__'] = [];
$_SESSION = [];
});

test('creates updates and reopens named filter JSON', function (): void {
$cid = $this->campaign->id;
$data = ['name' => 'Ember', 'schema_id' => 2, 'target_type' => 'none', 'filters' => json_encode([[['field' => 'param:brand', 'op' => 'eq', 'value' => 'ember']]])];
$request = (new ServerRequestFactory())->createServerRequest('POST', '/')->withParsedBody($data);
expect($this->controller->create($request, new Response(), $cid)->getHeaderLine('Location'))->toBe("/admin/campaigns/{$cid}/edit#flows");
$flow = $this->flows->forCampaign($cid)[0];
expect($flow->filters)->toEqual(json_decode($data['filters'], true));
$data['filters'] = json_encode([[['field' => 'param:brand', 'op' => 'neq', 'value' => 'cinder']]]);
expect($this->controller->update($request->withParsedBody($data), new Response(), $cid, $flow->id)->getStatusCode())->toBe(302);
expect($this->flows->findById($flow->id)->filters)->toEqual(json_decode($data['filters'], true));
$html = (string)$this->controller->edit($request, new Response(), $this->view, $cid, $flow->id)->getBody();
expect(html_entity_decode($html, ENT_QUOTES))->toContain('param:brand', 'cinder', 'neq');
});

test('rejects invalid create and update and renders retained escaped filters', function (string $name): void {
$cid = $this->campaign->id;
$data = ['name' => 'Ember', 'schema_id' => 2, 'target_type' => 'none', 'filters' => json_encode([[['field' => 'param:' . $name, 'op' => 'neq', 'value' => 'cinder']]])];
$request = (new ServerRequestFactory())->createServerRequest('POST', '/')->withParsedBody($data);
expect($this->controller->create($request, new Response(), $cid)->getHeaderLine('Location'))->toBe("/admin/campaigns/{$cid}/flows/new");
expect($this->flows->forCampaign($cid))->toBe([]);
expect($_SESSION['_errors'])->toHaveKey('filters', 'validation.pattern');
expect($_SESSION['_old']['filters'])->toBe($data['filters']);
// Same per-request snapshot as SessionMiddleware.
$GLOBALS['__old_bag__'] = $_SESSION['_old'];
$html = (string)$this->controller->new_($request, new Response(), $this->view, $cid)->getBody();
expect(html_entity_decode($html, ENT_QUOTES))->toContain('param:' . $name, 'cinder', 'neq');
expect($html)->not->toContain('<img src=x onerror=alert(1)>')->toContain('form-error');
$flow = $this->flows->create($cid, ['name' => 'Saved', 'schema_id' => 2, 'filters' => '[]']);
expect($this->controller->update($request, new Response(), $cid, $flow->id)->getHeaderLine('Location'))->toBe("/admin/campaigns/{$cid}/flows/{$flow->id}/edit");
expect($this->flows->findById($flow->id)->filters)->toBe([]);
$GLOBALS['__old_bag__'] = $_SESSION['_old'];
$html = (string)$this->controller->edit($request, new Response(), $this->view, $cid, $flow->id)->getBody();
expect(html_entity_decode($html, ENT_QUOTES))->toContain('param:' . $name, 'cinder', 'neq');
})->with(['', 'bad.name', str_repeat('a', 65), '<img src=x onerror=alert(1)>']);
Loading