diff --git a/LICENSE b/LICENSE index c245a4e0..e58a6f71 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016-2023 Alejandro Celaya +Copyright (c) 2016-2024 Alejandro Celaya Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index 051e18dd..785c8341 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -17,7 +17,6 @@ use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler; use function sprintf; return (static function (): array { - $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; @@ -32,9 +31,10 @@ return (static function (): array { ...ConfigProvider::applyRoutesPrefix([ Action\HealthAction::getRouteDef(), + // Visits and rules routes must go first, as they have a more specific path, otherwise, when + // multi-segment slugs are enabled, routes with a less-specific path might match first + // Visits. - // These routes must go first, as they have a more specific path, otherwise, when multi-segment slugs - // are enabled, routes with a less-specific path might match first Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\Visit\TagVisitsAction::getRouteDef(), @@ -44,15 +44,17 @@ return (static function (): array { Action\Visit\DeleteOrphanVisitsAction::getRouteDef(), Action\Visit\NonOrphanVisitsAction::getRouteDef(), + //Redirect rules + Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), + // Short URLs Action\ShortUrl\CreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, $dropDomainMiddleware, $overrideDomainMiddleware, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, ]), Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, + Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class, $overrideDomainMiddleware, ]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), diff --git a/docs/swagger/definitions/ShortUrlRedirectRule.json b/docs/swagger/definitions/ShortUrlRedirectRule.json new file mode 100644 index 00000000..74cdd216 --- /dev/null +++ b/docs/swagger/definitions/ShortUrlRedirectRule.json @@ -0,0 +1,39 @@ +{ + "type": "object", + "required": ["priority", "longUrl", "conditions"], + "properties": { + "longUrl": { + "description": "Long URL to redirect to when this condition matches", + "type": "string" + }, + "priority": { + "description": "Order in which attempting to match the rule. Lower goes first", + "type": "number" + }, + "conditions": { + "description": "List of conditions that need to match in order to consider this rule matches", + "type": "array", + "items": { + "type": "object", + "required": ["name", "type", "matchKey", "matchValue"], + "properties": { + "name": { + "type": "string", + "description": "Unique condition name" + }, + "type": { + "type": "string", + "enum": ["device", "language", "query"], + "description": "The type of the condition, which will condition the logic used to match it" + }, + "matchKey": { + "type": ["string", "null"] + }, + "matchValue": { + "type": "string" + } + } + } + } + } +} diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json new file mode 100644 index 00000000..da4e95f5 --- /dev/null +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -0,0 +1,144 @@ +{ + "get": { + "operationId": "listShortUrlRedirectRules", + "tags": [ + "Redirect rules" + ], + "summary": "List short URL redirect rules", + "description": "Returns the list of redirect rules for a short URL.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "$ref": "../parameters/shortCode.json" + }, + { + "$ref": "../parameters/domain.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "The list of rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + }, + "example": { + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "priority": 1, + "conditions": [ + { + "name": "device-android", + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "name": "language-en-US", + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "priority": 2, + "conditions": [ + { + "name": "language-fr", + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "priority": 3, + "conditions": [ + { + "name": "query-foo-bar", + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "name": "query-hello-world", + "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": { + "API v3 and newer": { + "$ref": "../examples/short-url-not-found-v3.json" + }, + "Previous to API v3": { + "$ref": "../examples/short-url-not-found-v2.json" + } + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 51655ecf..1b34b470 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -42,6 +42,10 @@ "name": "Short URLs", "description": "Operations that can be performed on short URLs" }, + { + "name": "Redirect rules", + "description": "Handle dynamic rule-based redirects" + }, { "name": "Tags", "description": "Let you handle the list of available tags" @@ -79,6 +83,10 @@ "$ref": "paths/v1_short-urls_{shortCode}.json" }, + "/rest/v{version}/short-urls/{shortCode}/redirect-rules": { + "$ref": "paths/v3_short-urls_{shortCode}_redirect-rules.json" + }, + "/rest/v{version}/tags": { "$ref": "paths/v1_tags.json" }, diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 6246b307..ed64a30e 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -32,6 +32,7 @@ return [ Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'], Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'], + RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class, RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class, ShortUrl\UrlShortener::class => ConfigAbstractFactory::class, @@ -158,7 +159,9 @@ return [ Util\RedirectResponseHelper::class => [Options\RedirectOptions::class], Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'], - RedirectRule\ShortUrlRedirectionResolver::class => ['em'], + + RedirectRule\ShortUrlRedirectRuleService::class => ['em'], + RedirectRule\ShortUrlRedirectionResolver::class => [RedirectRule\ShortUrlRedirectRuleService::class], Action\RedirectAction::class => [ ShortUrl\ShortUrlResolver::class, diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 9505e81d..f5af53af 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -2,6 +2,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; +use JsonSerializable; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Model\DeviceType; @@ -15,7 +16,7 @@ use function sprintf; use function strtolower; use function trim; -class RedirectCondition extends AbstractEntity +class RedirectCondition extends AbstractEntity implements JsonSerializable { private function __construct( public readonly string $name, @@ -98,4 +99,14 @@ class RedirectCondition extends AbstractEntity $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); return $device !== null && $device->value === strtolower($this->matchValue); } + + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'type' => $this->type->value, + 'matchKey' => $this->matchKey, + 'matchValue' => $this->matchValue, + ]; + } } diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 9e84e4fb..72bcfa99 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -4,13 +4,15 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use JsonSerializable; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use function array_values; use function Shlinkio\Shlink\Core\ArrayUtils\every; -class ShortUrlRedirectRule extends AbstractEntity +class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable { /** * @param Collection $conditions @@ -33,4 +35,13 @@ class ShortUrlRedirectRule extends AbstractEntity static fn (RedirectCondition $condition) => $condition->matchesRequest($request), ); } + + public function jsonSerialize(): array + { + return [ + 'longUrl' => $this->longUrl, + 'priority' => $this->priority, + 'conditions' => array_values($this->conditions->toArray()), + ]; + } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php new file mode 100644 index 00000000..03d40095 --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -0,0 +1,25 @@ +em->getRepository(ShortUrlRedirectRule::class)->findBy( + criteria: ['shortUrl' => $shortUrl], + orderBy: ['priority' => 'ASC'], + ); + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php new file mode 100644 index 00000000..cda82910 --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php @@ -0,0 +1,14 @@ +em->getRepository(ShortUrlRedirectRule::class)->findBy( - criteria: ['shortUrl' => $shortUrl], - orderBy: ['priority' => 'ASC'], - ); + $rules = $this->ruleService->rulesForShortUrl($shortUrl); foreach ($rules as $rule) { // Return the long URL for the first rule found that matches if ($rule->matchesRequest($request)) { diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 4fd0d015..42d274c0 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -48,6 +48,9 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface return $shortUrl; } + /** + * @throws ShortUrlNotFoundException + */ public function resolvePublicShortUrl(ShortUrlIdentifier $identifier): ShortUrl { /** @var ShortUrlRepository $shortUrlRepo */ diff --git a/module/Core/src/ShortUrl/ShortUrlService.php b/module/Core/src/ShortUrl/ShortUrlService.php index 1c3e9295..d75f847d 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -13,13 +13,13 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlService implements ShortUrlServiceInterface +readonly class ShortUrlService implements ShortUrlServiceInterface { public function __construct( - private readonly ORM\EntityManagerInterface $em, - private readonly ShortUrlResolverInterface $urlResolver, - private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - private readonly ShortUrlRelationResolverInterface $relationResolver, + private ORM\EntityManagerInterface $em, + private ShortUrlResolverInterface $urlResolver, + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private ShortUrlRelationResolverInterface $relationResolver, ) { } diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php new file mode 100644 index 00000000..016c5453 --- /dev/null +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -0,0 +1,55 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->ruleService = new ShortUrlRedirectRuleService($this->em); + } + + #[Test] + public function delegatesToRepository(): void + { + $shortUrl = ShortUrl::withLongUrl('https://shlink.io'); + $rules = [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ + RedirectCondition::forLanguage('en-US'), + ])), + new ShortUrlRedirectRule($shortUrl, 2, 'https://example.com/from-rule-2', new ArrayCollection([ + RedirectCondition::forQueryParam('foo', 'bar'), + RedirectCondition::forDevice(DeviceType::ANDROID), + ])), + ]; + + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once())->method('findBy')->with( + ['shortUrl' => $shortUrl], + ['priority' => 'ASC'], + )->willReturn($rules); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( + $repo, + ); + + $result = $this->ruleService->rulesForShortUrl($shortUrl); + + self::assertSame($rules, $result); + } +} diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php index f8962b12..5bf435b2 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -3,8 +3,6 @@ namespace ShlinkioTest\Shlink\Core\RedirectRule; use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -15,6 +13,7 @@ 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\ShortUrlRedirectionResolver; +use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; @@ -25,12 +24,12 @@ use const ShlinkioTest\Shlink\IOS_USER_AGENT; class ShortUrlRedirectionResolverTest extends TestCase { private ShortUrlRedirectionResolver $resolver; - private EntityManagerInterface & MockObject $em; + private ShortUrlRedirectRuleServiceInterface & MockObject $ruleService; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); - $this->resolver = new ShortUrlRedirectionResolver($this->em); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + $this->resolver = new ShortUrlRedirectionResolver($this->ruleService); } #[Test, DataProvider('provideData')] @@ -43,14 +42,12 @@ class ShortUrlRedirectionResolverTest extends TestCase 'longUrl' => 'https://example.com/foo/bar', ])); - $repo = $this->createMock(EntityRepository::class); - $repo->expects($this->once())->method('findBy')->willReturn($condition !== null ? [ - new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ - $condition, - ])), - ] : []); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( - $repo, + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn( + $condition !== null ? [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ + $condition, + ])), + ] : [], ); $result = $this->resolver->resolveLongUrl($shortUrl, $request); diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 67343e27..4eabfec9 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -12,6 +12,7 @@ use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options; +use Shlinkio\Shlink\Core\RedirectRule; use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Tag\TagService; @@ -46,6 +47,7 @@ return [ Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, + Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -103,6 +105,10 @@ return [ Action\Tag\UpdateTagAction::class => [TagService::class], Action\Domain\ListDomainsAction::class => [DomainService::class, Options\NotFoundRedirectOptions::class], Action\Domain\DomainRedirectsAction::class => [DomainService::class], + Action\RedirectRule\ListRedirectRulesAction::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/ListRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php new file mode 100644 index 00000000..c4db3e12 --- /dev/null +++ b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php @@ -0,0 +1,35 @@ +urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); + $rules = $this->ruleService->rulesForShortUrl($shortUrl); + + return new JsonResponse(['redirectRules' => $rules]); + } +}