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', ],