diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 3cdcdaa28c0..871413917e9 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -70,6 +70,7 @@ - It’s now possible to reference custom field handles in element queries’ `where` params. ([#16318](https://github.com/craftcms/cms/pull/16318)) - Number fields’ scalar values now return an integer if Decimals is set to `0`, and a number formatted with the correct decimal points when using MySQL. ([16369](https://github.com/craftcms/cms/issues/16369)) - Added support for specifying the current site via an `X-Craft-Site` header set to a site ID or handle. ([#16367](https://github.com/craftcms/cms/pull/16367)) +- Added support for defining redirects from `config/redirects.php`. ([#16355](https://github.com/craftcms/cms/pull/16355)) - Deprecated the `ucfirst` Twig filter. `capitalize` should be used instead. ### Extensibility @@ -93,6 +94,7 @@ - Added `craft\elements\db\ElementQueryInterface::getFieldLayouts()`. - Added `craft\elements\db\NestedElementQueryTrait::fieldLayouts()`. - Added `craft\events\DefineAltActionsEvent`. +- Added `craft\events\RedirectEvent`. ([#16355](https://github.com/craftcms/cms/pull/16355)) - Added `craft\fieldlayoutelements\BaseField::actionMenuItems()`. - Added `craft\fieldlayoutelements\BaseField::isCrossSiteCopyable()`. - Added `craft\fields\BaseRelationField::gqlFieldArguments()`. @@ -122,6 +124,9 @@ - Added `craft\models\MailSettings::$siteOverrides`. - Added `craft\services\Elements::canSaveCanonical()`. - Added `craft\services\Gql::getFieldLayoutArguments()`. +- Added `craft\web\ErrorHandler::EVENT_BEFORE_REDIRECT`. ([#16355](https://github.com/craftcms/cms/pull/16355)) +- Added `craft\web\RedirectRule`. ([#16355](https://github.com/craftcms/cms/pull/16355)) +- Added `craft\web\UrlRule::regexTokens()`. - Added `craft\web\User::getImpersonator()`. - Added `craft\web\User::getImpersonatorId()`. - Added `craft\web\User::setImpersonatorId()`. @@ -137,6 +142,7 @@ - `craft\models\Site` now implements `craft\base\Chippable`. - `craft\services\Revisions::createRevision()` no longer creates the revision if an `EVENT_BEFORE_CREATE_REVISION` event handler sets `$event->handled` to `true` and at least one revision already exists for the element. ([#16260](https://github.com/craftcms/cms/discussions/16260)) - Elements’ `defineCardAttributes()` methods can now return a `placeholder` value set to a callable. +- Deprecated `craft\controllers\RedirectController`. ([#16355](https://github.com/craftcms/cms/pull/16355)) - Deprecated `craft\elements\User::EVENT_REGISTER_USER_ACTIONS`. - Deprecated `craft\elements\User::IMPERSONATE_KEY`. `craft\web\User::getImpersonatorId()` should be used instead. - Deprecated `craft\fields\Color::$presets`. ([#16249](https://github.com/craftcms/cms/pull/16249)) diff --git a/src/controllers/RedirectController.php b/src/controllers/RedirectController.php index bfcf667f3d9..f790c377f58 100644 --- a/src/controllers/RedirectController.php +++ b/src/controllers/RedirectController.php @@ -17,6 +17,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.5.13 + * @deprecated in 5.6.0. `config/redirects.php` should be used instead. */ class RedirectController extends Controller { diff --git a/src/events/RedirectEvent.php b/src/events/RedirectEvent.php new file mode 100644 index 00000000000..3e9912fe495 --- /dev/null +++ b/src/events/RedirectEvent.php @@ -0,0 +1,22 @@ + + * @since 5.6.0 + */ +class RedirectEvent extends Event +{ + public RedirectRule $rule; +} diff --git a/src/web/ErrorHandler.php b/src/web/ErrorHandler.php index 16b80b11c54..a8bb386d218 100644 --- a/src/web/ErrorHandler.php +++ b/src/web/ErrorHandler.php @@ -9,6 +9,7 @@ use Craft; use craft\events\ExceptionEvent; +use craft\events\RedirectEvent; use craft\helpers\App; use craft\helpers\Json; use craft\helpers\Template; @@ -38,6 +39,12 @@ class ErrorHandler extends \yii\web\ErrorHandler */ public const EVENT_BEFORE_HANDLE_EXCEPTION = 'beforeHandleException'; + /** + * @event RedirectEvent The event that is triggered before a 404 redirect. + * @since 5.6.0 + */ + public const EVENT_BEFORE_REDIRECT = 'beforeRedirect'; + /** * @inheritdoc */ @@ -57,6 +64,36 @@ public function handleException($exception): void // 404? if ($exception instanceof HttpException && $exception->statusCode === 404) { + $redirectRules = Craft::$app->getConfig()->getConfigFromFile('redirects'); + if ($redirectRules) { + foreach ($redirectRules as $from => $rule) { + if (!$rule instanceof RedirectRule) { + $config = is_string($rule) ? ['to' => $rule] : $rule; + $rule = Craft::createObject([ + 'class' => RedirectRule::class, + 'from' => $from, + ...$config, + ]); + } + + $url = $rule->getMatch(); + + if ($url === null) { + continue; + } + + if ($this->hasEventHandlers(self::EVENT_BEFORE_REDIRECT)) { + $this->trigger(self::EVENT_BEFORE_REDIRECT, new RedirectEvent([ + 'rule' => $rule, + ])); + ; + } + + Craft::$app->getResponse()->redirect($url, $rule->statusCode); + Craft::$app->end(); + } + } + $request = Craft::$app->getRequest(); if ($request->getIsSiteRequest() && $request->getPathInfo() === 'wp-admin') { $exception->statusCode = 418; diff --git a/src/web/RedirectRule.php b/src/web/RedirectRule.php new file mode 100644 index 00000000000..efc359a0793 --- /dev/null +++ b/src/web/RedirectRule.php @@ -0,0 +1,119 @@ + + * @since 5.6.0 + */ +class RedirectRule extends Component +{ + /** + * @event \yii\base\Event The event that is triggered before redirecting the request. + */ + public const EVENT_BEFORE_REDIRECT = 'beforeRedirect'; + + public string $to; + public string $from; + public int $statusCode = 302; + public bool $caseSensitive = false; + private Closure $_match; + private array $regexTokens = []; + + public function __invoke(): void + { + $url = $this->getMatch(); + + if ($url === null) { + return; + } + + if ($this->hasEventHandlers(self::EVENT_BEFORE_REDIRECT)) { + $this->trigger(self::EVENT_BEFORE_REDIRECT); + } + + Craft::$app->getResponse()->redirect($url, $this->statusCode); + Craft::$app->end(); + } + + public function getMatch(): ?string + { + if (isset($this->_match)) { + return ($this->_match)(Http::new(Craft::$app->getRequest()->getAbsoluteUrl())); + } + + $subject = Craft::$app->getRequest()->getFullPath(); + + if (str_contains($this->from, '<')) { + if (preg_match( + $this->toRegexPattern($this->from), + $subject, + $matches, + )) { + return $this->replaceParams($this->to, $matches); + } + + return null; + } + + if ($this->caseSensitive) { + return strcmp($this->from, $subject) === 0 ? $this->to : null; + } + + return strcasecmp($this->from, $subject) === 0 ? $this->to : null; + } + + public function setMatch(callable $match): void + { + $this->_match = $match; + } + + private function replaceParams(string $value, array $params): string + { + $params = Collection::make($params) + ->mapWithKeys(fn($item, $key) => ["<$key>" => $item]); + + return strtr($value, $params->all()); + } + + private function toRegexPattern(string $from): string + { + // Tokenize the patterns first, so we only escape regex chars outside of patterns + $tokenizedPattern = preg_replace_callback('/<([\w._-]+):?([^>]+)?>/', function($match) { + $name = $match[1]; + $pattern = strtr($match[2] ?? '[^\/]+', UrlRule::regexTokens()); + $token = "<$name>"; + $this->regexTokens[$token] = "(?P<$name>$pattern)"; + + return $token; + }, $from); + + $replacements = array_merge($this->regexTokens, [ + '.' => '\\.', + '*' => '\\*', + '$' => '\\$', + '[' => '\\[', + ']' => '\\]', + '(' => '\\(', + ')' => '\\)', + ]); + + $pattern = strtr($tokenizedPattern, $replacements); + $flags = $this->caseSensitive ? 'u' : 'iu'; + + return "`^{$pattern}$`{$flags}"; + } +} diff --git a/src/web/UrlRule.php b/src/web/UrlRule.php index f5797d83d22..648a91a3659 100644 --- a/src/web/UrlRule.php +++ b/src/web/UrlRule.php @@ -10,6 +10,7 @@ use Craft; use craft\helpers\ArrayHelper; use craft\helpers\StringHelper; +use craft\validators\HandleValidator; /** * @inheritdoc @@ -18,6 +19,28 @@ */ class UrlRule extends \yii\web\UrlRule { + /** + * Returns an array of regex tokens supported by URL rules. + * + * @return array + * @since 5.6.0 + */ + public static function regexTokens(): array + { + $slugChars = ['.', '_', '-']; + $slugWordSeparator = Craft::$app->getConfig()->getGeneral()->slugWordSeparator; + if ($slugWordSeparator !== '/' && !in_array($slugWordSeparator, $slugChars, true)) { + $slugChars[] = $slugWordSeparator; + } + + return [ + '{handle}' => sprintf('(?:%s)', HandleValidator::$handlePattern), + // Reference: http://www.regular-expressions.info/unicode.html + '{slug}' => sprintf('(?:[\p{L}\p{N}\p{M}%s]+)', preg_quote(implode($slugChars), '/')), + '{uid}' => sprintf('(?:%s)', StringHelper::UUID_PATTERN), + ]; + } + /** * @var array Pattern tokens that will be swapped out at runtime. */ @@ -50,19 +73,7 @@ public function __construct(array $config = []) if (isset($config['pattern'])) { // Swap out any regex tokens in the pattern if (!isset(self::$_regexTokens)) { - $slugChars = ['.', '_', '-']; - $slugWordSeparator = Craft::$app->getConfig()->getGeneral()->slugWordSeparator; - - if ($slugWordSeparator !== '/' && !in_array($slugWordSeparator, $slugChars, true)) { - $slugChars[] = $slugWordSeparator; - } - - // Reference: http://www.regular-expressions.info/unicode.html - self::$_regexTokens = [ - '{handle}' => '(?:[a-zA-Z][a-zA-Z0-9_]*)', - '{slug}' => '(?:[\p{L}\p{N}\p{M}' . preg_quote(implode($slugChars), '/') . ']+)', - '{uid}' => StringHelper::UUID_PATTERN, - ]; + self::$_regexTokens = static::regexTokens(); } $config['pattern'] = strtr($config['pattern'], self::$_regexTokens); diff --git a/tests/unit/web/RedirectRuleTest.php b/tests/unit/web/RedirectRuleTest.php new file mode 100644 index 00000000000..6b220417bb5 --- /dev/null +++ b/tests/unit/web/RedirectRuleTest.php @@ -0,0 +1,144 @@ + + * @since 5.6.0 + */ +class RedirectRuleTest extends TestCase +{ + /** + * @var UnitTester + */ + protected UnitTester $tester; + + /** + * @dataProvider getMatchDataProvider + * @param string|null $expected + * @param array $config + */ + public function testGetMatch(?string $expected, string $path, ?string $url, array $config): void + { + $this->tester->mockCraftMethods('request', [ + 'getFullPath' => $path, + 'getAbsoluteUrl' => $url, + ]); + $rule = new RedirectRule($config); + $this->assertSame($expected, $rule->getMatch()); + } + + public static function getMatchDataProvider(): array + { + return [ + // Path match (case-insensitive by default) + [ + null, + 'nope', + UrlHelper::url('nope'), + [ + 'from' => 'redirect/from', + 'to' => 'redirect/to', + ], + ], + [ + 'redirect/to', + 'redirect/from', + UrlHelper::url('redirect/from'), + [ + 'from' => 'redirect/from', + 'to' => 'redirect/to', + ], + ], + [ + 'redirect/to', + 'redirect/from/$special.chars', + UrlHelper::url('redirect/from/$special.chars'), + [ + 'from' => 'redirect/from/$special.chars', + 'to' => 'redirect/to', + ], + ], + + // Path match with Yii URL Rule named parameters + // https://www.yiiframework.com/doc/guide/2.0/en/runtime-routing#named-parameters + [ + 'redirect/to/abc123', + 'redirect/from/foo/abc123', + UrlHelper::url('redirect/from/foo/abc123'), + [ + 'from' => 'redirect/from/foo/', + 'to' => 'redirect/to/', + ], + ], + [ + 'redirect/to/abc-123', + 'redirect/from/foo/abc-123', + UrlHelper::url('redirect/from/foo/abc-123'), + [ + 'from' => 'redirect/from/foo/', + 'to' => 'redirect/to/', + ], + ], + [ + 'redirect/to/55a89943-19a6-4f5e-8db7-8950f7f66e98', + 'redirect/from/foo/55a89943-19a6-4f5e-8db7-8950f7f66e98', + UrlHelper::url('redirect/from/foo/55a89943-19a6-4f5e-8db7-8950f7f66e98'), + [ + 'from' => 'redirect/from/foo/', + 'to' => 'redirect/to/', + ], + ], + + // Path match (case-sensitive) + [ + 'https://redirect.to/2025/01', + 'redirect/FROM/2025/01', + UrlHelper::url('redirect/FROM/2025/01'), + [ + 'from' => 'redirect/FROM//', + 'to' => 'https://redirect.to//', + 'caseSensitive' => true, + ], + ], + [ + null, + 'redirect/from/2025/01', + UrlHelper::url('redirect/from/2025/01'), + [ + 'from' => 'redirect/FROM//', + 'to' => 'https://redirect.to//', + 'caseSensitive' => true, + ], + ], + + // Custom match callback + [ + 'redirect/to/abc123', + 'redirect/from', + UrlHelper::url('redirect/from', ['bar' => 'abc123']), + [ + 'match' => function(UriInterface $url): ?string { + parse_str($url->getQuery(), $params); + return isset($params['bar']) + ? sprintf('redirect/to/%s', $params['bar']) + : null; + }, + ], + ], + ]; + } +}