Skip to content

Commit

Permalink
Merge pull request #16355 from craftcms/feature/redirects
Browse files Browse the repository at this point in the history
Redirects via config file
  • Loading branch information
brandonkelly authored Jan 11, 2025
2 parents c5b7de4 + d0e0977 commit 62d99d8
Show file tree
Hide file tree
Showing 7 changed files with 353 additions and 13 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG-WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()`.
Expand Down Expand Up @@ -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()`.
Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions src/controllers/RedirectController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*
* @author Pixel & Tonic, Inc. <[email protected]>
* @since 3.5.13
* @deprecated in 5.6.0. `config/redirects.php` should be used instead.
*/
class RedirectController extends Controller
{
Expand Down
22 changes: 22 additions & 0 deletions src/events/RedirectEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\events;

use craft\base\Event;
use craft\web\RedirectRule;

/**
* RedirectEvent class.
*
* @author Pixel & Tonic, Inc. <[email protected]>
* @since 5.6.0
*/
class RedirectEvent extends Event
{
public RedirectRule $rule;
}
37 changes: 37 additions & 0 deletions src/web/ErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand All @@ -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;
Expand Down
119 changes: 119 additions & 0 deletions src/web/RedirectRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/

namespace craft\web;

use Closure;
use Craft;
use Illuminate\Support\Collection;
use League\Uri\Http;
use yii\base\Component;

/**
* Class RedirectRule
*
* @author Pixel & Tonic, Inc. <[email protected]>
* @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}";
}
}
37 changes: 24 additions & 13 deletions src/web/UrlRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Craft;
use craft\helpers\ArrayHelper;
use craft\helpers\StringHelper;
use craft\validators\HandleValidator;

/**
* @inheritdoc
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 62d99d8

Please sign in to comment.