From ee3ecbf9d2190b5471ff8ecaaf15dd667e878feb Mon Sep 17 00:00:00 2001 From: Rias Date: Fri, 22 May 2026 20:19:08 +0200 Subject: [PATCH 1/3] Refactor action request routing --- src/Http/Middleware/HandleActionRequest.php | 17 +++-- src/Http/Mixins/RequestMixin.php | 32 ++------- src/Http/Routing/ActionRoute.php | 47 ++++++++++++ src/Http/Routing/ActionRouteResolver.php | 63 ++++++++++++++++ src/Route/DynamicRoute.php | 3 +- .../Http/Middleware/CheckForUpdatesTest.php | 10 --- .../Middleware/HandleActionRequestTest.php | 17 ++++- tests/Unit/Http/RequestMixinTest.php | 6 +- .../Http/Routing/ActionRouteResolverTest.php | 72 +++++++++++++++++++ 9 files changed, 220 insertions(+), 47 deletions(-) create mode 100644 src/Http/Routing/ActionRoute.php create mode 100644 src/Http/Routing/ActionRouteResolver.php create mode 100644 tests/Unit/Http/Routing/ActionRouteResolverTest.php diff --git a/src/Http/Middleware/HandleActionRequest.php b/src/Http/Middleware/HandleActionRequest.php index b88702dcfb9..77864666309 100644 --- a/src/Http/Middleware/HandleActionRequest.php +++ b/src/Http/Middleware/HandleActionRequest.php @@ -5,23 +5,30 @@ namespace CraftCms\Cms\Http\Middleware; use Closure; +use CraftCms\Cms\Http\Routing\ActionRoute; +use CraftCms\Cms\Http\Routing\ActionRouteResolver; use Illuminate\Http\Request; readonly class HandleActionRequest { + public function __construct( + private ActionRouteResolver $actionRoutes, + ) {} + public function handle(Request $request, Closure $next): mixed { - if (! $request->isActionRequest()) { + $actionRoute = $this->actionRoutes->resolve($request); + + if ($actionRoute === null) { return $next($request); } - $route = $request->actionSegmentsToRoute(); - - if ($request->path() === $route) { + if ($actionRoute->matches($request)) { return $next($request); } - $newRequest = $request->duplicateWithUri($route); + $newRequest = $request->duplicateWithUri($actionRoute->uri); + $newRequest->attributes->set(ActionRoute::class, $actionRoute); app()->instance('request', $newRequest); diff --git a/src/Http/Mixins/RequestMixin.php b/src/Http/Mixins/RequestMixin.php index 5b649d5bc4e..7046aab5e69 100644 --- a/src/Http/Mixins/RequestMixin.php +++ b/src/Http/Mixins/RequestMixin.php @@ -7,6 +7,7 @@ use Closure; use CraftCms\Cms\Cms; use CraftCms\Cms\Http\Middleware\HandleTokenRequest; +use CraftCms\Cms\Http\Routing\ActionRouteResolver; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Context; @@ -167,7 +168,7 @@ public function isActionRequest(): Closure */ $request = $this; - return $request->actionSegments() !== []; + return app(ActionRouteResolver::class)->resolve($request) !== null; }; } @@ -241,30 +242,14 @@ public function actionSegments(): Closure * @phpstan-ignore-next-line */ $request = $this; - $actionTrigger = Cms::config()->actionTrigger; - $segmentIndex = $request->isCpRequest() ? 2 : 1; - if ($request->segment($segmentIndex) === $actionTrigger && count($request->segments()) > $segmentIndex) { - return array_slice($request->segments(), $segmentIndex); - } - - $actionParam = $request->input('action'); - - if ($actionParam !== null) { - if (! is_string($actionParam)) { - abort(400, 'Invalid action param'); - } - - return array_values(array_filter(explode('/', $actionParam))); - } - - return []; + return app(ActionRouteResolver::class)->resolve($request)?->segments ?? []; }; } public function actionSegmentsToRoute(): Closure { - return function (?array $actionSegments = null): string { + return function (): string { /** * @var Request $request * @@ -272,14 +257,7 @@ public function actionSegmentsToRoute(): Closure */ $request = $this; - $actionSegments ??= $request->actionSegments(); - - return implode('/', array_filter([ - '', - $request->isCpRequest() ? Cms::config()->cpTrigger : null, - Cms::config()->actionTrigger, - ...$actionSegments, - ], fn ($value) => $value !== null)); + return app(ActionRouteResolver::class)->resolve($request)?->uri ?? ''; }; } diff --git a/src/Http/Routing/ActionRoute.php b/src/Http/Routing/ActionRoute.php new file mode 100644 index 00000000000..4c0321203ba --- /dev/null +++ b/src/Http/Routing/ActionRoute.php @@ -0,0 +1,47 @@ +cpTrigger : null, + Cms::config()->actionTrigger, + ...$segments, + ], fn ($value) => $value !== null)); + } + + public function matches(Request $request): bool + { + return '/'.ltrim($request->path(), '/') === $this->uri; + } +} diff --git a/src/Http/Routing/ActionRouteResolver.php b/src/Http/Routing/ActionRouteResolver.php new file mode 100644 index 00000000000..0600cc92f4b --- /dev/null +++ b/src/Http/Routing/ActionRouteResolver.php @@ -0,0 +1,63 @@ +attributes->get(ActionRoute::class); + + if ($cached instanceof ActionRoute) { + return $cached; + } + + $segments = $this->segmentsFromPath($request) ?? $this->segmentsFromActionParam($request); + + if ($segments === null) { + return null; + } + + $actionRoute = ActionRoute::fromSegments($segments, $request->isCpRequest()); + + if ($actionRoute !== null) { + $request->attributes->set(ActionRoute::class, $actionRoute); + } + + return $actionRoute; + } + + private function segmentsFromPath(Request $request): ?array + { + $actionTrigger = Cms::config()->actionTrigger; + $segmentIndex = $request->isCpRequest() ? 2 : 1; + + if ($request->segment($segmentIndex) === $actionTrigger && count($request->segments()) > $segmentIndex) { + return array_slice($request->segments(), $segmentIndex); + } + + return null; + } + + private function segmentsFromActionParam(Request $request): ?array + { + $actionParam = $request->input('action'); + + if ($actionParam === null) { + return null; + } + + if (! is_string($actionParam)) { + abort(400, 'Invalid action param'); + } + + $segments = array_values(array_filter(explode('/', $actionParam))); + + return $segments === [] ? null : $segments; + } +} diff --git a/src/Route/DynamicRoute.php b/src/Route/DynamicRoute.php index 8306ab87ef2..341c4d628f6 100644 --- a/src/Route/DynamicRoute.php +++ b/src/Route/DynamicRoute.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Route; use CraftCms\Cms\Cms; +use CraftCms\Cms\Http\Routing\ActionRoute; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Str; use CraftCms\Cms\Twig\Exceptions\TemplateLoaderException; @@ -36,7 +37,7 @@ public function handle(Request $request): Response } return app()->make(Kernel::class)->handle($request->duplicateWithUri( - newUri: $request->actionSegmentsToRoute(explode('/', trim($this->route, '/'))), + newUri: ActionRoute::uriForSegments(explode('/', trim($this->route, '/')), $request->isCpRequest()), query: $variables, )); } diff --git a/tests/Feature/Http/Middleware/CheckForUpdatesTest.php b/tests/Feature/Http/Middleware/CheckForUpdatesTest.php index 858042ddbf5..e9fe2b975c2 100644 --- a/tests/Feature/Http/Middleware/CheckForUpdatesTest.php +++ b/tests/Feature/Http/Middleware/CheckForUpdatesTest.php @@ -123,8 +123,6 @@ $middleware = app(CheckForUpdates::class); $request = Request::create('/actions/updater/migrate'); - $request->attributes->set('isActionRequest', true); - $request->attributes->set('actionSegments', ['updater', 'migrate']); $result = $middleware->handle($request, fn () => 'passed'); @@ -136,8 +134,6 @@ $middleware = app(CheckForUpdates::class); $request = Request::create('/actions/app/health-check'); - $request->attributes->set('isActionRequest', true); - $request->attributes->set('actionSegments', ['app', 'health-check']); $result = $middleware->handle($request, fn () => 'passed'); @@ -149,8 +145,6 @@ $middleware = app(CheckForUpdates::class); $request = Request::create('/actions/app/migrate'); - $request->attributes->set('isActionRequest', true); - $request->attributes->set('actionSegments', ['app', 'migrate']); $result = $middleware->handle($request, fn () => 'passed'); @@ -162,8 +156,6 @@ $middleware = app(CheckForUpdates::class); $request = Request::create('/actions/pluginstore/install/migrate'); - $request->attributes->set('isActionRequest', true); - $request->attributes->set('actionSegments', ['pluginstore', 'install', 'migrate']); $result = $middleware->handle($request, fn () => 'passed'); @@ -189,8 +181,6 @@ $middleware = app(CheckForUpdates::class); $request = Request::create('/actions/entries/save'); - $request->attributes->set('isActionRequest', true); - $request->attributes->set('actionSegments', ['entries', 'save']); $middleware->handle($request, fn () => 'passed'); })->throws(HttpException::class); diff --git a/tests/Unit/Http/Middleware/HandleActionRequestTest.php b/tests/Unit/Http/Middleware/HandleActionRequestTest.php index 9a5a0c4b065..00b7a0c617b 100644 --- a/tests/Unit/Http/Middleware/HandleActionRequestTest.php +++ b/tests/Unit/Http/Middleware/HandleActionRequestTest.php @@ -4,6 +4,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Http\Middleware\HandleActionRequest; +use CraftCms\Cms\Http\Routing\ActionRoute; use Illuminate\Http\Request; beforeEach(function () { @@ -23,7 +24,21 @@ ); expect($handledRequest->path())->toBe('admin/actions/query/execute') - ->and(request())->toBe($handledRequest); + ->and(request())->toBe($handledRequest) + ->and($handledRequest->attributes->get(ActionRoute::class))->toBeInstanceOf(ActionRoute::class); +}); + +it('does not rebind action requests that already use the normalized action uri', function () { + $request = Request::create('/admin/actions/query/execute', 'POST'); + app()->instance('request', $request); + + $handledRequest = app(HandleActionRequest::class)->handle( + $request, + fn (Request $request) => $request, + ); + + expect($handledRequest)->toBe($request) + ->and(request())->toBe($request); }); it('does not rebind non-action requests', function () { diff --git a/tests/Unit/Http/RequestMixinTest.php b/tests/Unit/Http/RequestMixinTest.php index 291e954be9f..c3d7a108e1d 100644 --- a/tests/Unit/Http/RequestMixinTest.php +++ b/tests/Unit/Http/RequestMixinTest.php @@ -145,9 +145,9 @@ ->toBe('/admin/actions/users/login'); }); - it('builds a route from explicit action segments', function () { - expect(Request::create('/news')->actionSegmentsToRoute(['users', 'login'])) - ->toBe('/actions/users/login'); + it('returns an empty string when the current request is not an action request', function () { + expect(Request::create('/news')->actionSegmentsToRoute()) + ->toBe(''); }); }); diff --git a/tests/Unit/Http/Routing/ActionRouteResolverTest.php b/tests/Unit/Http/Routing/ActionRouteResolverTest.php new file mode 100644 index 00000000000..82dfc7d7cad --- /dev/null +++ b/tests/Unit/Http/Routing/ActionRouteResolverTest.php @@ -0,0 +1,72 @@ +cpTrigger = 'admin'; + Cms::config()->actionTrigger = 'actions'; +}); + +it('resolves site action routes from the request path', function () { + $request = Request::create('/actions/users/login'); + + $actionRoute = app(ActionRouteResolver::class)->resolve($request); + + expect($actionRoute)->toBeInstanceOf(ActionRoute::class) + ->and($actionRoute->segments)->toBe(['users', 'login']) + ->and($actionRoute->uri)->toBe('/actions/users/login') + ->and($actionRoute->isCp)->toBeFalse() + ->and($request->attributes->get(ActionRoute::class))->toBe($actionRoute); +}); + +it('resolves control panel action routes from the request path', function () { + $request = Request::create('/admin/actions/users/login'); + + $actionRoute = app(ActionRouteResolver::class)->resolve($request); + + expect($actionRoute)->toBeInstanceOf(ActionRoute::class) + ->and($actionRoute->segments)->toBe(['users', 'login']) + ->and($actionRoute->uri)->toBe('/admin/actions/users/login') + ->and($actionRoute->isCp)->toBeTrue(); +}); + +it('resolves action routes from the action param', function () { + $request = Request::create('/admin/utilities/query', 'POST', [ + 'action' => 'query/execute', + ]); + + $actionRoute = app(ActionRouteResolver::class)->resolve($request); + + expect($actionRoute)->toBeInstanceOf(ActionRoute::class) + ->and($actionRoute->segments)->toBe(['query', 'execute']) + ->and($actionRoute->uri)->toBe('/admin/actions/query/execute') + ->and($actionRoute->isCp)->toBeTrue(); +}); + +it('returns null when there is no action route', function () { + expect(app(ActionRouteResolver::class)->resolve(Request::create('/news')))->toBeNull(); +}); + +it('returns null when the action param has no segments', function () { + expect(app(ActionRouteResolver::class)->resolve(Request::create('/news?action=/')))->toBeNull(); +}); + +it('aborts when the action param is not a string', function () { + $request = Request::create('/news', 'GET', [ + 'action' => ['users/login'], + ]); + + expect(fn () => app(ActionRouteResolver::class)->resolve($request)) + ->toThrow(HttpException::class, 'Invalid action param'); +}); + +it('builds action uris from explicit segments', function () { + expect(ActionRoute::uriForSegments(['users', 'login'], false))->toBe('/actions/users/login') + ->and(ActionRoute::uriForSegments(['users', 'login'], true))->toBe('/admin/actions/users/login'); +}); From d324f435e0b51fa95f0fe0a27d5baae122bd79da Mon Sep 17 00:00:00 2001 From: Rias Date: Fri, 22 May 2026 20:25:01 +0200 Subject: [PATCH 2/3] Phpstan fixes --- src/Http/Mixins/RequestMixin.php | 4 ++-- yii2-adapter/legacy/web/Application.php | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Http/Mixins/RequestMixin.php b/src/Http/Mixins/RequestMixin.php index 7046aab5e69..58f8053372c 100644 --- a/src/Http/Mixins/RequestMixin.php +++ b/src/Http/Mixins/RequestMixin.php @@ -243,7 +243,7 @@ public function actionSegments(): Closure */ $request = $this; - return app(ActionRouteResolver::class)->resolve($request)?->segments ?? []; + return app(ActionRouteResolver::class)->resolve($request)->segments ?? []; }; } @@ -257,7 +257,7 @@ public function actionSegmentsToRoute(): Closure */ $request = $this; - return app(ActionRouteResolver::class)->resolve($request)?->uri ?? ''; + return app(ActionRouteResolver::class)->resolve($request)->uri ?? ''; }; } diff --git a/yii2-adapter/legacy/web/Application.php b/yii2-adapter/legacy/web/Application.php index 83b04b6ad21..f985e3ddbf9 100644 --- a/yii2-adapter/legacy/web/Application.php +++ b/yii2-adapter/legacy/web/Application.php @@ -14,6 +14,7 @@ use craft\queue\QueueLogBehavior; use CraftCms\Aliases\Aliases; use CraftCms\Cms\Cms; +use CraftCms\Cms\Http\Routing\ActionRoute; use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Route\DynamicRoute; use CraftCms\Cms\Site\Sites; @@ -268,9 +269,9 @@ private function runDefaultControllerAction(string $route, array $params = []): private function runLaravelAction(string $route, array $params = []): ?BaseResponse { - $actionUri = request()->actionSegmentsToRoute(explode('/', $route)); + $actionUri = ActionRoute::uriForSegments(explode('/', $route), request()->isCpRequest()); - if ($actionUri === null) { + if (empty($actionUri)) { return null; } From 0942bf0a8f0b3a060d066d0250c9428907c99876 Mon Sep 17 00:00:00 2001 From: Rias Date: Fri, 22 May 2026 20:35:25 +0200 Subject: [PATCH 3/3] Clear cached action route on request duplication --- src/Http/Mixins/RequestMixin.php | 3 +++ tests/Unit/Http/RequestMixinTest.php | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Http/Mixins/RequestMixin.php b/src/Http/Mixins/RequestMixin.php index 58f8053372c..18f1d61de64 100644 --- a/src/Http/Mixins/RequestMixin.php +++ b/src/Http/Mixins/RequestMixin.php @@ -7,6 +7,7 @@ use Closure; use CraftCms\Cms\Cms; use CraftCms\Cms\Http\Middleware\HandleTokenRequest; +use CraftCms\Cms\Http\Routing\ActionRoute; use CraftCms\Cms\Http\Routing\ActionRouteResolver; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Http\Request; @@ -282,6 +283,8 @@ public function duplicateWithUri(): Closure $duplicatedRequest->setLaravelSession($request->session()); } + $duplicatedRequest->attributes->remove(ActionRoute::class); + return $duplicatedRequest; }; } diff --git a/tests/Unit/Http/RequestMixinTest.php b/tests/Unit/Http/RequestMixinTest.php index c3d7a108e1d..cf9dd790d41 100644 --- a/tests/Unit/Http/RequestMixinTest.php +++ b/tests/Unit/Http/RequestMixinTest.php @@ -4,6 +4,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Http\Middleware\HandleTokenRequest; +use CraftCms\Cms\Http\Routing\ActionRoute; use Illuminate\Http\Request; use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Crypt; @@ -201,6 +202,15 @@ expect($duplicate->hasSession())->toBeTrue() ->and($duplicate->session())->toBe($request->session()); }); + + it('does not preserve resolved action routes on the duplicated request', function () { + $request = Request::create('/actions/users/login'); + $request->attributes->set(ActionRoute::class, ActionRoute::fromSegments(['users', 'login'], false)); + + $duplicate = $request->duplicateWithUri('/entries'); + + expect($duplicate->attributes->has(ActionRoute::class))->toBeFalse(); + }); }); describe('getSigned', function () {