diff --git a/config/autoload/rabbit.local.php.dist b/config/autoload/rabbit.local.php.dist
index 83cd4a88..b758528e 100644
--- a/config/autoload/rabbit.local.php.dist
+++ b/config/autoload/rabbit.local.php.dist
@@ -7,6 +7,7 @@ return [
'rabbitmq' => [
'enabled' => true,
'host' => 'shlink_rabbitmq',
+ 'port' => '5673',
'user' => 'rabbit',
'password' => 'rabbit',
],
diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php
index 55f06bbf..aad5e9d0 100644
--- a/config/test/test_config.global.php
+++ b/config/test/test_config.global.php
@@ -52,7 +52,7 @@ $buildDbConnection = static function (): array {
'postgres' => [
'driver' => 'pdo_pgsql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
- 'port' => $isCi ? '5433' : '5432',
+ 'port' => $isCi ? '5434' : '5432',
'user' => 'postgres',
'password' => 'root',
'dbname' => 'shlink_test',
diff --git a/docker-compose.yml b/docker-compose.yml
index 3f65e4bb..ccc5fc2d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -81,7 +81,7 @@ services:
container_name: shlink_db_postgres
image: postgres:12.2-alpine
ports:
- - "5433:5432"
+ - "5434:5432"
volumes:
- ./:/home/shlink/www
- ./data/infra/database_pg:/var/lib/postgresql/data
@@ -153,8 +153,8 @@ services:
container_name: shlink_rabbitmq
image: rabbitmq:3.11-management-alpine
ports:
- - "15672:15672"
- - "5672:5672"
+ - "15673:15672"
+ - "5673:5672"
environment:
RABBITMQ_DEFAULT_USER: "rabbit"
RABBITMQ_DEFAULT_PASS: "rabbit"
diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php
index bcd4fd3c..94237c15 100644
--- a/module/CLI/config/cli.config.php
+++ b/module/CLI/config/cli.config.php
@@ -37,6 +37,9 @@ return [
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
+
+ Command\RedirectRule\ManageRedirectRulesCommand::NAME =>
+ Command\RedirectRule\ManageRedirectRulesCommand::class,
],
],
diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php
index 2736a21e..c2b61e19 100644
--- a/module/CLI/config/dependencies.config.php
+++ b/module/CLI/config/dependencies.config.php
@@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
+use Shlinkio\Shlink\Core\RedirectRule;
use Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Tag\TagService;
@@ -66,6 +67,8 @@ return [
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class,
+
+ Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class,
],
],
@@ -117,6 +120,11 @@ return [
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
+ Command\RedirectRule\ManageRedirectRulesCommand::class => [
+ ShortUrl\ShortUrlResolver::class,
+ RedirectRule\ShortUrlRedirectRuleService::class,
+ ],
+
Command\Db\CreateDatabaseCommand::class => [
LockFactory::class,
Util\ProcessRunner::class,
diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php
new file mode 100644
index 00000000..84741bbe
--- /dev/null
+++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php
@@ -0,0 +1,272 @@
+setName(self::NAME)
+ ->setDescription('Set redirect rules for a short URL')
+ ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which rules we want to set.')
+ ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $identifier = ShortUrlIdentifier::fromCli($input);
+
+ try {
+ $shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier);
+ } catch (ShortUrlNotFoundException) {
+ $io->error(sprintf('Short URL for %s not found', $identifier->__toString()));
+ return ExitCode::EXIT_FAILURE;
+ }
+
+ $rulesToSave = $this->processRules($shortUrl, $io, $this->ruleService->rulesForShortUrl($shortUrl));
+ if ($rulesToSave !== null) {
+ $this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave);
+ }
+
+ return ExitCode::EXIT_SUCCESS;
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $rules
+ * @return ShortUrlRedirectRule[]|null
+ */
+ private function processRules(ShortUrl $shortUrl, SymfonyStyle $io, array $rules): ?array
+ {
+ $amountOfRules = count($rules);
+
+ if ($amountOfRules === 0) {
+ $io->comment('No rules found.');
+ } else {
+ $listing = map(
+ $rules,
+ function (ShortUrlRedirectRule $rule, string|int|float $index) use ($amountOfRules): array {
+ $priority = ((int) $index) + 1;
+ $conditions = $rule->mapConditions(static fn (RedirectCondition $condition): string => sprintf(
+ '%s',
+ $condition->toHumanFriendly(),
+ ));
+
+ return [
+ str_pad((string) $priority, strlen((string) $amountOfRules), '0', STR_PAD_LEFT),
+ implode(' AND ', $conditions),
+ $rule->longUrl,
+ ];
+ },
+ );
+ $io->table(['Priority', 'Conditions', 'Redirect to'], $listing);
+ }
+
+ $action = $io->choice(
+ 'What do you want to do next?',
+ [
+ 'Add new rule',
+ 'Remove existing rule',
+ 'Re-arrange rule',
+ 'Discard changes',
+ 'Save and exit',
+ ],
+ 'Save and exit',
+ );
+
+ return match ($action) {
+ 'Add new rule' => $this->processRules($shortUrl, $io, $this->addRule($shortUrl, $io, $rules)),
+ 'Remove existing rule' => $this->processRules($shortUrl, $io, $this->removeRule($io, $rules)),
+ 'Re-arrange rule' => $this->processRules($shortUrl, $io, $this->reArrangeRule($io, $rules)),
+ 'Save and exit' => $rules,
+ default => null,
+ };
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $currentRules
+ */
+ private function addRule(ShortUrl $shortUrl, SymfonyStyle $io, array $currentRules): array
+ {
+ $higherPriority = count($currentRules);
+ $priority = $this->askPriority($io, $higherPriority + 1);
+ $longUrl = $this->askLongUrl($io);
+ $conditions = [];
+
+ do {
+ $type = RedirectConditionType::from(
+ $io->choice('Type of the condition?', enumValues(RedirectConditionType::class)),
+ );
+ $conditions[] = match ($type) {
+ RedirectConditionType::DEVICE => RedirectCondition::forDevice(
+ DeviceType::from($io->choice('Device to match?', enumValues(DeviceType::class))),
+ ),
+ RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage(
+ $this->askMandatory('Language to match?', $io),
+ ),
+ RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam(
+ $this->askMandatory('Query param name?', $io),
+ $this->askOptional('Query param value?', $io),
+ ),
+ };
+
+ $continue = $io->confirm('Do you want to add another condition?');
+ } while ($continue);
+
+ $newRule = new ShortUrlRedirectRule($shortUrl, $priority, $longUrl, new ArrayCollection($conditions));
+ $rulesBefore = array_slice($currentRules, 0, $priority - 1);
+ $rulesAfter = array_slice($currentRules, $priority - 1);
+
+ return [...$rulesBefore, $newRule, ...$rulesAfter];
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $currentRules
+ */
+ private function removeRule(SymfonyStyle $io, array $currentRules): array
+ {
+ if (empty($currentRules)) {
+ $io->warning('There are no rules to remove');
+ return $currentRules;
+ }
+
+ $index = $this->askRule('What rule do you want to delete?', $io, $currentRules);
+ unset($currentRules[$index]);
+ return array_values($currentRules);
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $currentRules
+ */
+ private function reArrangeRule(SymfonyStyle $io, array $currentRules): array
+ {
+ if (empty($currentRules)) {
+ $io->warning('There are no rules to re-arrange');
+ return $currentRules;
+ }
+
+ $oldIndex = $this->askRule('What rule do you want to re-arrange?', $io, $currentRules);
+ $newIndex = $this->askPriority($io, count($currentRules)) - 1;
+
+ // Temporarily get rule from array and unset it
+ $rule = $currentRules[$oldIndex];
+ unset($currentRules[$oldIndex]);
+
+ // Reindex remaining rules
+ $currentRules = array_values($currentRules);
+
+ $rulesBefore = array_slice($currentRules, 0, $newIndex);
+ $rulesAfter = array_slice($currentRules, $newIndex);
+
+ return [...$rulesBefore, $rule, ...$rulesAfter];
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $currentRules
+ */
+ private function askRule(string $message, SymfonyStyle $io, array $currentRules): int
+ {
+ $choices = [];
+ foreach ($currentRules as $index => $rule) {
+ $choices[$rule->longUrl] = $index + 1;
+ }
+
+ $resp = $io->choice($message, array_flip($choices));
+ return $choices[$resp] - 1;
+ }
+
+ private function askPriority(SymfonyStyle $io, int $max): int
+ {
+ return $io->ask(
+ 'Rule priority (the lower the value, the higher the priority)',
+ (string) $max,
+ function (string $answer) use ($max): int {
+ if (! is_numeric($answer)) {
+ throw new InvalidArgumentException('The priority must be a numeric positive value');
+ }
+
+ $priority = (int) $answer;
+ return max(1, min($max, $priority));
+ },
+ );
+ }
+
+ private function askLongUrl(SymfonyStyle $io): string
+ {
+ return $io->ask(
+ 'Long URL to redirect when the rule matches',
+ validator: function (string $answer): string {
+ $validator = ShortUrlInputFilter::longUrlValidators();
+ if (! $validator->isValid($answer)) {
+ throw new InvalidArgumentException(implode(', ', $validator->getMessages()));
+ }
+
+ return $answer;
+ },
+ );
+ }
+
+ private function askMandatory(string $message, SymfonyStyle $io): string
+ {
+ return $io->ask($message, validator: function (?string $answer): string {
+ if ($answer === null) {
+ throw new InvalidArgumentException('The value is mandatory');
+ }
+ return trim($answer);
+ });
+ }
+
+ private function askOptional(string $message, SymfonyStyle $io): string
+ {
+ return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer));
+ }
+}
diff --git a/module/Core/functions/array-utils.php b/module/Core/functions/array-utils.php
index 5fb636e6..7b9ca7e5 100644
--- a/module/Core/functions/array-utils.php
+++ b/module/Core/functions/array-utils.php
@@ -72,3 +72,20 @@ function select_keys(array $array, array $keys): array
ARRAY_FILTER_USE_KEY,
);
}
+
+/**
+ * @template T
+ * @template R
+ * @param iterable $collection
+ * @param callable(T $value, string|number $key): R $callback
+ * @return R[]
+ */
+function map(iterable $collection, callable $callback): array
+{
+ $aggregation = [];
+ foreach ($collection as $key => $value) {
+ $aggregation[$key] = $callback($value, $key);
+ }
+
+ return $aggregation;
+}
diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php
index 72cfdf49..29123733 100644
--- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php
+++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php
@@ -13,6 +13,7 @@ use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function Shlinkio\Shlink\Core\normalizeLocale;
use function Shlinkio\Shlink\Core\splitLocale;
+use function sprintf;
use function strtolower;
use function trim;
@@ -107,4 +108,17 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
'matchValue' => $this->matchValue,
];
}
+
+ public function toHumanFriendly(): string
+ {
+ return match ($this->type) {
+ RedirectConditionType::DEVICE => sprintf('device is %s', $this->matchValue),
+ RedirectConditionType::LANGUAGE => sprintf('%s language is accepted', $this->matchValue),
+ RedirectConditionType::QUERY_PARAM => sprintf(
+ 'query string contains %s=%s',
+ $this->matchKey,
+ $this->matchValue,
+ ),
+ };
+ }
}
diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php
index 4469a620..57ad7092 100644
--- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php
+++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php
@@ -15,7 +15,7 @@ use function Shlinkio\Shlink\Core\ArrayUtils\every;
class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
{
/**
- * @param Collection $conditions
+ * @param Collection $conditions
*/
public function __construct(
private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine
@@ -41,6 +41,16 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
$this->conditions->clear();
}
+ /**
+ * @template R
+ * @param callable(RedirectCondition $condition): R $callback
+ * @return R[]
+ */
+ public function mapConditions(callable $callback): array
+ {
+ return $this->conditions->map($callback(...))->toArray();
+ }
+
public function jsonSerialize(): array
{
return [
diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php
index 51076068..c00cca7f 100644
--- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php
+++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php
@@ -6,5 +6,5 @@ enum RedirectConditionType: string
{
case DEVICE = 'device';
case LANGUAGE = 'language';
- case QUERY_PARAM = 'query';
+ case QUERY_PARAM = 'query-param';
}
diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php
index 1a770ae9..40bbb0de 100644
--- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php
+++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php
@@ -34,23 +34,6 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic
*/
public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array
{
- return $this->em->wrapInTransaction(fn () => $this->doSetRulesForShortUrl($shortUrl, $data));
- }
-
- /**
- * @return ShortUrlRedirectRule[]
- */
- private function doSetRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array
- {
- // First, delete existing rules for the short URL
- $oldRules = $this->rulesForShortUrl($shortUrl);
- foreach ($oldRules as $oldRule) {
- $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions
- $this->em->remove($oldRule);
- }
- $this->em->flush();
-
- // Then insert new rules
$rules = [];
foreach ($data->rules as $index => $rule) {
$rule = new ShortUrlRedirectRule(
@@ -64,9 +47,30 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic
);
$rules[] = $rule;
- $this->em->persist($rule);
}
+ $this->saveRulesForShortUrl($shortUrl, $rules);
return $rules;
}
+
+ /**
+ * @param ShortUrlRedirectRule[] $rules
+ */
+ public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void
+ {
+ $this->em->wrapInTransaction(function () use ($shortUrl, $rules): void {
+ // First, delete existing rules for the short URL
+ $oldRules = $this->rulesForShortUrl($shortUrl);
+ foreach ($oldRules as $oldRule) {
+ $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions
+ $this->em->remove($oldRule);
+ }
+ $this->em->flush();
+
+ // Then insert new rules
+ foreach ($rules as $rule) {
+ $this->em->persist($rule);
+ }
+ });
+ }
}
diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php
index 7fc34a1b..186be87e 100644
--- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php
+++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php
@@ -17,4 +17,9 @@ interface ShortUrlRedirectRuleServiceInterface
* @return ShortUrlRedirectRule[]
*/
public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array;
+
+ /**
+ * @param ShortUrlRedirectRule[] $rules
+ */
+ public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void;
}
diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php
index 22000e2c..e8d35284 100644
--- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php
+++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php
@@ -124,6 +124,9 @@ class ShortUrlInputFilter extends InputFilter
$this->add($apiKeyInput);
}
+ /**
+ * @todo Extract to its own validator class
+ */
public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain
{
$emptyModifiers = [
diff --git a/module/Rest/test-api/Action/ListRedirectRulesTest.php b/module/Rest/test-api/Action/ListRedirectRulesTest.php
index b86683c9..c53986c1 100644
--- a/module/Rest/test-api/Action/ListRedirectRulesTest.php
+++ b/module/Rest/test-api/Action/ListRedirectRulesTest.php
@@ -18,7 +18,7 @@ class ListRedirectRulesTest extends ApiTestCase
'matchValue' => 'en',
];
private const QUERY_FOO_BAR_CONDITION = [
- 'type' => 'query',
+ 'type' => 'query-param',
'matchKey' => 'foo',
'matchValue' => 'bar',
];
@@ -53,7 +53,7 @@ class ListRedirectRulesTest extends ApiTestCase
'priority' => 2,
'conditions' => [
[
- 'type' => 'query',
+ 'type' => 'query-param',
'matchKey' => 'hello',
'matchValue' => 'world',
],
diff --git a/module/Rest/test-api/Action/SetRedirectRulesTest.php b/module/Rest/test-api/Action/SetRedirectRulesTest.php
index c70fd0ea..a1172d65 100644
--- a/module/Rest/test-api/Action/SetRedirectRulesTest.php
+++ b/module/Rest/test-api/Action/SetRedirectRulesTest.php
@@ -19,7 +19,7 @@ class SetRedirectRulesTest extends ApiTestCase
'matchValue' => 'en',
];
private const QUERY_FOO_BAR_CONDITION = [
- 'type' => 'query',
+ 'type' => 'query-param',
'matchKey' => 'foo',
'matchValue' => 'bar',
];
@@ -75,7 +75,7 @@ class SetRedirectRulesTest extends ApiTestCase
'priority' => 2,
'conditions' => [
[
- 'type' => 'query',
+ 'type' => 'query-param',
'matchKey' => 'hello',
'matchValue' => 'world',
],