diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist index e818404b..13a74022 100644 --- a/config/autoload/mercure.local.php.dist +++ b/config/autoload/mercure.local.php.dist @@ -5,7 +5,7 @@ declare(strict_types=1); return [ 'mercure' => [ - 'public_hub_url' => 'http://localhost:8001', + 'public_hub_url' => 'http://localhost:8002', 'internal_hub_url' => 'http://shlink_mercure_proxy', 'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error', ], diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index 785c8341..6d072228 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -46,6 +46,7 @@ return (static function (): array { //Redirect rules Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), + Action\RedirectRule\SetRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), // Short URLs Action\ShortUrl\CreateShortUrlAction::getRouteDef([ diff --git a/docker-compose.yml b/docker-compose.yml index 5416136d..3f65e4bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,7 +131,7 @@ services: container_name: shlink_mercure_proxy image: nginx:1.25-alpine ports: - - "8001:80" + - "8002:80" volumes: - ./:/home/shlink/www - ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf diff --git a/docs/swagger/definitions/ShortUrlRedirectRule.json b/docs/swagger/definitions/ShortUrlRedirectRule.json index 74cdd216..8fde6e90 100644 --- a/docs/swagger/definitions/ShortUrlRedirectRule.json +++ b/docs/swagger/definitions/ShortUrlRedirectRule.json @@ -15,12 +15,8 @@ "type": "array", "items": { "type": "object", - "required": ["name", "type", "matchKey", "matchValue"], + "required": ["type", "matchKey", "matchValue"], "properties": { - "name": { - "type": "string", - "description": "Unique condition name" - }, "type": { "type": "string", "enum": ["device", "language", "query"], diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json index 44cd2d86..cd2904d4 100644 --- a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -137,5 +137,164 @@ } } } + }, + + "post": { + "operationId": "setShortUrlRedirectRules", + "tags": [ + "Redirect rules" + ], + "summary": "Set short URL redirect rules", + "description": "Overwrites redirect rules for a short URL with the ones provided here.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "$ref": "../parameters/shortCode.json" + }, + { + "$ref": "../parameters/domain.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "requestBody": { + "description": "Request body.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The list of rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["defaultLongUrl", "redirectRules"], + "properties": { + "defaultLongUrl": { + "type": "string" + }, + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + }, + "example": { + "defaultLongUrl": "https://example.com", + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "priority": 1, + "conditions": [ + { + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "priority": 2, + "conditions": [ + { + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "priority": 3, + "conditions": [ + { + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "type": "query", + "matchKey": "hello", + "matchValue": "world" + } + ] + } + ] + } + } + } + }, + "404": { + "description": "No URL was found for provided short code.", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["shortCode"], + "properties": { + "shortCode": { + "type": "string", + "description": "The short code with which we tried to find the short URL" + }, + "domain": { + "type": "string", + "description": "The domain with which we tried to find the short URL" + } + } + } + ] + }, + "examples": { + "Short URL not found": { + "$ref": "../examples/short-url-not-found-v3.json" + } + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } } } diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 72bcfa99..4469a620 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -36,6 +36,11 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable ); } + public function clearConditions(): void + { + $this->conditions->clear(); + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/RedirectRule/Model/RedirectRulesData.php b/module/Core/src/RedirectRule/Model/RedirectRulesData.php new file mode 100644 index 00000000..6eb7dada --- /dev/null +++ b/module/Core/src/RedirectRule/Model/RedirectRulesData.php @@ -0,0 +1,25 @@ +isValid()) { + throw ValidationException::fromInputFilter($inputFilter); + } + + return new self($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES)); + } +} diff --git a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php new file mode 100644 index 00000000..745b8914 --- /dev/null +++ b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php @@ -0,0 +1,92 @@ +setInputFilter(self::createRedirectRuleInputFilter()); + + $instance = new self(); + $instance->add($redirectRulesInputFilter, self::REDIRECT_RULES); + + $instance->setData($rawData); + return $instance; + } + + private static function createRedirectRuleInputFilter(): InputFilter + { + $redirectRuleInputFilter = new InputFilter(); + + $redirectRuleInputFilter->add(InputFactory::numeric(self::RULE_PRIORITY, required: true)); + + $longUrl = InputFactory::basic(self::RULE_LONG_URL, required: true); + $longUrl->setValidatorChain(ShortUrlInputFilter::longUrlValidators()); + $redirectRuleInputFilter->add($longUrl); + + $conditionsInputFilter = new CollectionInputFilter(); + $conditionsInputFilter->setInputFilter(self::createRedirectConditionInputFilter()) + ->setIsRequired(true); + $redirectRuleInputFilter->add($conditionsInputFilter, self::RULE_CONDITIONS); + + return $redirectRuleInputFilter; + } + + private static function createRedirectConditionInputFilter(): InputFilter + { + $redirectConditionInputFilter = new InputFilter(); + + $type = InputFactory::basic(self::CONDITION_TYPE, required: true); + $type->getValidatorChain()->attach(new InArray([ + 'haystack' => enumValues(RedirectConditionType::class), + 'strict' => InArray::COMPARE_STRICT, + ])); + $redirectConditionInputFilter->add($type); + + $value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true); + $value->getValidatorChain()->attach(new Callback(function (string $value, array $context) { + if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) { + return contains($value, enumValues(DeviceType::class)); + } + + return true; + })); + $redirectConditionInputFilter->add($value); + + $redirectConditionInputFilter->add( + InputFactory::basic(self::CONDITION_MATCH_KEY, required: true)->setAllowEmpty(true), + ); + + return $redirectConditionInputFilter; + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php index 03d40095..b3ad1f07 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -2,10 +2,18 @@ namespace Shlinkio\Shlink\Core\RedirectRule; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; +use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use function array_map; + readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface { public function __construct(private EntityManagerInterface $em) @@ -22,4 +30,52 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic orderBy: ['priority' => 'ASC'], ); } + + /** + * @return ShortUrlRedirectRule[] + */ + public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array + { + return $this->em->wrapInTransaction(function () use ($shortUrl, $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 $rule) { + $rule = new ShortUrlRedirectRule( + shortUrl: $shortUrl, + priority: $rule[RedirectRulesInputFilter::RULE_PRIORITY], + longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL], + conditions: new ArrayCollection(array_map( + fn (array $conditionData) => $this->createCondition($conditionData), + $rule[RedirectRulesInputFilter::RULE_CONDITIONS], + )), + ); + + $rules[] = $rule; + $this->em->persist($rule); + } + + return $rules; + }); + } + + private function createCondition(array $rawConditionData): RedirectCondition + { + $type = RedirectConditionType::from($rawConditionData[RedirectRulesInputFilter::CONDITION_TYPE]); + $value = $rawConditionData[RedirectRulesInputFilter::CONDITION_MATCH_VALUE]; + $key = $rawConditionData[RedirectRulesInputFilter::CONDITION_MATCH_KEY]; + + return match ($type) { + RedirectConditionType::DEVICE => RedirectCondition::forDevice(DeviceType::from($value)), + RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage($value), + RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam($key, $value), + }; + } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php index cda82910..7fc34a1b 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php @@ -3,6 +3,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; interface ShortUrlRedirectRuleServiceInterface @@ -11,4 +12,9 @@ interface ShortUrlRedirectRuleServiceInterface * @return ShortUrlRedirectRule[] */ public function rulesForShortUrl(ShortUrl $shortUrl): array; + + /** + * @return ShortUrlRedirectRule[] + */ + public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array; } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 8818e0f6..22000e2c 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -93,7 +93,7 @@ class ShortUrlInputFilter extends InputFilter private function initializeForEdition(bool $requireLongUrl = false): void { $longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl); - $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); + $longUrlInput->getValidatorChain()->merge(self::longUrlValidators(allowNull: ! $requireLongUrl)); $this->add($longUrlInput); $validSince = InputFactory::basic(self::VALID_SINCE); @@ -124,7 +124,7 @@ class ShortUrlInputFilter extends InputFilter $this->add($apiKeyInput); } - private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain + public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain { $emptyModifiers = [ Validator\NotEmpty::OBJECT, diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 4eabfec9..9396dd38 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -48,6 +48,7 @@ return [ Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class, + Action\RedirectRule\SetRedirectRulesAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -109,6 +110,10 @@ return [ ShortUrl\ShortUrlResolver::class, RedirectRule\ShortUrlRedirectRuleService::class, ], + Action\RedirectRule\SetRedirectRulesAction::class => [ + ShortUrl\ShortUrlResolver::class, + RedirectRule\ShortUrlRedirectRuleService::class, + ], Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], diff --git a/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php new file mode 100644 index 00000000..913a833d --- /dev/null +++ b/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php @@ -0,0 +1,43 @@ +urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); + $data = RedirectRulesData::fromRawData((array) $request->getParsedBody()); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + return new JsonResponse([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $result, + ]); + } +}