diff --git a/packages/router/src/MatchRouteMiddleware.php b/packages/router/src/MatchRouteMiddleware.php index 05bc91969..775a4986d 100644 --- a/packages/router/src/MatchRouteMiddleware.php +++ b/packages/router/src/MatchRouteMiddleware.php @@ -24,6 +24,8 @@ public function __construct( public function __invoke(Request $request, HttpMiddlewareCallable $next): Response { + $request = $this->applyMethodSpoofing($request); + $matchedRoute = $this->routeMatcher->match($request); if ($matchedRoute === null && $request->method === Method::HEAD && $request instanceof GenericRequest) { @@ -70,4 +72,40 @@ private function resolveRequest(Request $request, MatchedRoute $matchedRoute): R return $request; } + + private function applyMethodSpoofing(Request $request): Request + { + if ($request->method !== Method::POST) { + return $request; + } + + if (! ($request instanceof GenericRequest)) { + return $request; + } + + if (! $request->hasBody('_method')) { + return $request; + } + + $spoofedMethod = $request->get('_method'); + $spoofedEnum = ($spoofedMethod instanceof Method) + ? $spoofedMethod + : Method::tryFrom(strtoupper((string) $spoofedMethod)); + + if ($spoofedEnum === null) { + return $request; + } + + $allowedMethods = [ + Method::PUT, + Method::PATCH, + Method::DELETE, + ]; + + if (! in_array($spoofedEnum, $allowedMethods, true)) { + return $request; + } + + return $request->withMethod($spoofedEnum); + } } diff --git a/packages/router/tests/MatchRouteMiddlewareTest.php b/packages/router/tests/MatchRouteMiddlewareTest.php new file mode 100644 index 000000000..e4e6f8b16 --- /dev/null +++ b/packages/router/tests/MatchRouteMiddlewareTest.php @@ -0,0 +1,281 @@ +container = new GenericContainer(); + $this->routeConfig = new RouteConfig(); + $this->container->singleton(RouteConfig::class, fn () => $this->routeConfig); + $routeMatcher = new GenericRouteMatcher($this->routeConfig); + $this->middleware = new MatchRouteMiddleware( + routeMatcher: $routeMatcher, + container: $this->container, + ); + } + + public function test_method_spoofing_with_put(): void + { + $this->addRoute(Method::PUT, '/users/1'); + + $request = new GenericRequest( + method: Method::POST, + uri: '/users/1', + body: ['_method' => 'PUT', 'name' => 'John'], + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + $matchedRoute = $this->container->get(MatchedRoute::class); + + $this->assertInstanceOf(Ok::class, $response); + $this->assertNotNull($matchedRoute); + $this->assertEquals(Method::PUT, $matchedRoute->route->method); + } + + public function test_method_spoofing_with_patch(): void + { + $this->addRoute(Method::PATCH, '/users/1'); + + $request = new GenericRequest( + method: Method::POST, + uri: '/users/1', + body: ['_method' => 'PATCH'], + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + $matchedRoute = $this->container->get(MatchedRoute::class); + + $this->assertInstanceOf(Ok::class, $response); + $this->assertNotNull($matchedRoute); + $this->assertEquals(Method::PATCH, $matchedRoute->route->method); + } + + public function test_method_spoofing_with_delete(): void + { + $this->addRoute(Method::DELETE, '/users/1'); + + $request = new GenericRequest( + method: Method::POST, + uri: '/users/1', + body: ['_method' => 'DELETE'], + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + $matchedRoute = $this->container->get(MatchedRoute::class); + + $this->assertInstanceOf(Ok::class, $response); + $this->assertNotNull($matchedRoute); + $this->assertEquals(Method::DELETE, $matchedRoute->route->method); + } + + public function test_method_spoofing_with_lowercase_method(): void + { + $this->addRoute(Method::PUT, '/users/1'); + + $request = new GenericRequest( + method: Method::POST, + uri: '/users/1', + body: ['_method' => 'put'], + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + $matchedRoute = $this->container->get(MatchedRoute::class); + + $this->assertInstanceOf(Ok::class, $response); + $this->assertNotNull($matchedRoute); + $this->assertEquals(Method::PUT, $matchedRoute->route->method); + } + + public function test_method_spoofing_ignores_invalid_method(): void + { + $this->addRoute(Method::POST, '/users/1'); + + $request = new GenericRequest( + method: Method::POST, + uri: '/users/1', + body: ['_method' => 'INVALID'], + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + $matchedRoute = $this->container->get(MatchedRoute::class); + + $this->assertInstanceOf(Ok::class, $response); + $this->assertNotNull($matchedRoute); + $this->assertEquals(Method::POST, $matchedRoute->route->method); + } + + public function test_method_spoofing_not_allowed_for_get(): void + { + $this->addRoute(Method::GET, '/users/1'); + $this->addRoute(Method::POST, '/users/1'); + + $request = new GenericRequest( + method: Method::POST, + uri: '/users/1', + body: ['_method' => 'GET'], + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + $matchedRoute = $this->container->get(MatchedRoute::class); + + $this->assertInstanceOf(Ok::class, $response); + $this->assertNotNull($matchedRoute); + $this->assertEquals(Method::POST, $matchedRoute->route->method); + } + + public function test_method_spoofing_only_applies_to_post(): void + { + $this->addRoute(Method::PUT, '/users/1'); + + $request = new GenericRequest( + method: Method::GET, + uri: '/users/1', + body: ['_method' => 'PUT'], + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + + $this->assertInstanceOf(NotFound::class, $response); + } + + public function test_no_spoofing_without_method_parameter(): void + { + $this->addRoute(Method::POST, '/users/1'); + + $request = new GenericRequest( + method: Method::POST, + uri: '/users/1', + body: ['name' => 'John'], + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + $matchedRoute = $this->container->get(MatchedRoute::class); + + $this->assertInstanceOf(Ok::class, $response); + $this->assertNotNull($matchedRoute); + $this->assertEquals(Method::POST, $matchedRoute->route->method); + } + + public function test_head_to_get_fallback_still_works(): void + { + $this->addRoute(Method::GET, '/users'); + + $request = new GenericRequest( + method: Method::HEAD, + uri: '/users', + ); + + $next = new HttpMiddlewareCallable( + fn (Request $_request) => new Ok('Middleware processed'), + ); + + $response = ($this->middleware)($request, $next); + $matchedRoute = $this->container->get(MatchedRoute::class); + + $this->assertInstanceOf(Ok::class, $response); + $this->assertNotNull($matchedRoute); + $this->assertEquals(Method::GET, $matchedRoute->route->method); + } + + public function test_method_spoofing_preserves_request_data(): void + { + $this->addRoute(Method::PUT, '/users/1'); + + $request = new GenericRequest( + method: Method::POST, + uri: '/users/1', + body: [ + '_method' => 'PUT', + 'name' => 'John', + 'email' => 'john@example.com', + ], + ); + + $processedRequest = null; + $next = new HttpMiddlewareCallable( + function (Request $request) use (&$processedRequest) { + $processedRequest = $request; + return new Ok('Middleware processed'); + }, + ); + + ($this->middleware)($request, $next); + + $this->assertNotNull($processedRequest); + $this->assertEquals('John', $processedRequest->get('name')); + $this->assertEquals('john@example.com', $processedRequest->get('email')); + $this->assertEquals('PUT', $processedRequest->get('_method')); + } + + private function addRoute(Method $method, string $uri): void + { + $route = new FakeRouteBuilder(); + $route = $route + ->withMethod($method) + ->withUri($uri) + ->asDiscoveredRoute(); + + if ($route->isDynamic) { + $this->routeConfig->dynamicRoutes[$method->value][$route->markName] = $route; + } else { + $this->routeConfig->staticRoutes[$method->value][$uri] = $route; + } + } +} diff --git a/packages/view/src/Components/x-form.view.php b/packages/view/src/Components/x-form.view.php index b8552869d..b368f4ffa 100644 --- a/packages/view/src/Components/x-form.view.php +++ b/packages/view/src/Components/x-form.view.php @@ -13,10 +13,17 @@ if ($method instanceof Method) { $method = $method->value; } + +$needsSpoofing = in_array(strtoupper($method), ['PUT', 'PATCH', 'DELETE'], true); +$formMethod = $needsSpoofing ? 'POST' : $method; ?> -
+ + + + +