diff --git a/src/Router/AdminRouteGenerator.php b/src/Router/AdminRouteGenerator.php index 88f9ce54d6..de2d2c58ed 100644 --- a/src/Router/AdminRouteGenerator.php +++ b/src/Router/AdminRouteGenerator.php @@ -236,6 +236,13 @@ private function generateAdminRoutes(): array ]; $adminRoute = new Route($adminRoutePath, defaults: $defaults, methods: $actionRouteConfig['methods']); + + if (null !== $dashboardRouteConfig['routeHost']) { + $adminRoute->setHost($dashboardRouteConfig['routeHost']); + $adminRoute->addDefaults($dashboardRouteConfig['routeHostDefaults']); + $adminRoute->addRequirements($dashboardRouteConfig['routeHostRequirements']); + } + $adminRoutes[$adminRouteName] = $adminRoute; $addedRouteNames[] = $adminRouteName; } @@ -425,12 +432,21 @@ private function createRouteForAdminAttribute(AdminRoute $adminRouteAttribute, s $route = new Route($routePath); $routeOptions = $adminRouteAttribute->options; + $dashboardRouteConfig = $this->getDashboardsRouteConfig()[$dashboardFqcn]; - if (isset($routeOptions['requirements'])) { - $route->setRequirements($routeOptions['requirements']); - } + $hostDefaults = []; + $hostRequirements = []; if (isset($routeOptions['host'])) { $route->setHost($routeOptions['host']); + } elseif (null !== $dashboardRouteConfig['routeHost']) { + $route->setHost($dashboardRouteConfig['routeHost']); + $hostDefaults = $dashboardRouteConfig['routeHostDefaults']; + $hostRequirements = $dashboardRouteConfig['routeHostRequirements']; + } + + $requirements = array_merge($hostRequirements, $routeOptions['requirements'] ?? []); + if ([] !== $requirements) { + $route->setRequirements($requirements); } if (isset($routeOptions['methods'])) { $route->setMethods($routeOptions['methods']); @@ -442,7 +458,7 @@ private function createRouteForAdminAttribute(AdminRoute $adminRouteAttribute, s $route->setCondition($routeOptions['condition']); } - $defaults = $routeOptions['defaults'] ?? []; + $defaults = array_merge($hostDefaults, $routeOptions['defaults'] ?? []); if (isset($routeOptions['locale'])) { $defaults['_locale'] = $routeOptions['locale']; } @@ -520,7 +536,36 @@ private function getDefaultRoutesConfig(string $dashboardFqcn): array } /** - * @return array + * Returns the host of the dashboard route and the defaults and requirements of the + * variables used by that host, which must be applied to all the routes that inherit it. + * + * @param array $routeOptions + * + * @return array{host: string|null, defaults: array, requirements: array} + */ + private function getRouteHostConfig(array $routeOptions): array + { + $host = $routeOptions['host'] ?? null; + if (null === $host || '' === $host) { + return ['host' => null, 'defaults' => [], 'requirements' => []]; + } + + $defaults = []; + $requirements = []; + foreach ((new Route('/', host: $host))->compile()->getHostVariables() as $variableName) { + if (\array_key_exists($variableName, $routeOptions['defaults'] ?? [])) { + $defaults[$variableName] = $routeOptions['defaults'][$variableName]; + } + if (isset($routeOptions['requirements'][$variableName])) { + $requirements[$variableName] = $routeOptions['requirements'][$variableName]; + } + } + + return ['host' => $host, 'defaults' => $defaults, 'requirements' => $requirements]; + } + + /** + * @return array, routeHostRequirements: array}> */ private function getDashboardsRouteConfig(): array { @@ -537,6 +582,7 @@ private function getDashboardsRouteConfig(): array $adminDashboardAttribute = $attributes[0]->newInstance(); $routeName = $adminDashboardAttribute->routeName; $routePath = $adminDashboardAttribute->routePath; + $routeHostConfig = $this->getRouteHostConfig($adminDashboardAttribute->routeOptions); if (null !== $routePath) { $routePath = rtrim($adminDashboardAttribute->routePath, '/'); } @@ -545,6 +591,9 @@ private function getDashboardsRouteConfig(): array $config[$reflectionClass->getName()] = [ 'routeName' => $routeName, 'routePath' => $routePath, + 'routeHost' => $routeHostConfig['host'], + 'routeHostDefaults' => $routeHostConfig['defaults'], + 'routeHostRequirements' => $routeHostConfig['requirements'], ]; continue; @@ -583,12 +632,24 @@ private function getDashboardsRouteConfig(): array } $routeAttribute = $attributes[0]->newInstance(); + $routeHostConfig = $this->getRouteHostConfig([ + /** @phpstan-ignore-next-line */ + 'host' => (method_exists($routeAttribute, 'getHost') ? $routeAttribute->getHost() : $routeAttribute->host) ?: null, + /** @phpstan-ignore-next-line */ + 'defaults' => method_exists($routeAttribute, 'getDefaults') ? $routeAttribute->getDefaults() : $routeAttribute->defaults, + /** @phpstan-ignore-next-line */ + 'requirements' => method_exists($routeAttribute, 'getRequirements') ? $routeAttribute->getRequirements() : $routeAttribute->requirements, + ]); + $config[$reflectionClass->getName()] = [ // Symfony 8 removed the getName() and getPath() methods in favor of public properties /** @phpstan-ignore-next-line */ 'routeName' => method_exists($routeAttribute, 'getName') ? $routeAttribute->getName() : $routeAttribute->name, /** @phpstan-ignore-next-line */ 'routePath' => rtrim(method_exists($routeAttribute, 'getPath') ? $routeAttribute->getPath() : $routeAttribute->path, '/'), + 'routeHost' => $routeHostConfig['host'], + 'routeHostDefaults' => $routeHostConfig['defaults'], + 'routeHostRequirements' => $routeHostConfig['requirements'], ]; } diff --git a/tests/Functional/AdminRoute/AdminRouteTest.php b/tests/Functional/AdminRoute/AdminRouteTest.php index b1cfb9e6d7..71447eb861 100644 --- a/tests/Functional/AdminRoute/AdminRouteTest.php +++ b/tests/Functional/AdminRoute/AdminRouteTest.php @@ -9,6 +9,11 @@ use EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\AdminRouteApp\Kernel; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\Matcher\UrlMatcher; +use Symfony\Component\Routing\RequestContext; /** * @group legacy @@ -160,6 +165,190 @@ public function testStandaloneMethodRoutes(): void $this->assertNull($router->getRouteCollection()->get('admin_standalone')); } + public function testDashboardRouteHostIsInheritedByCrudRoutes(): void + { + $client = static::createClient(); + $router = $client->getContainer()->get('router'); + + $dashboardRoute = $router->getRouteCollection()->get('host_admin'); + $this->assertNotNull($dashboardRoute); + $this->assertSame('backend.example.com', $dashboardRoute->getHost()); + + $indexRoute = $router->getRouteCollection()->get('host_admin_built_in_action_list'); + $this->assertNotNull($indexRoute); + $this->assertSame('backend.example.com', $indexRoute->getHost()); + + $deleteRoute = $router->getRouteCollection()->get('host_admin_built_in_action_delete'); + $this->assertNotNull($deleteRoute); + $this->assertSame('backend.example.com', $deleteRoute->getHost()); + } + + public function testDashboardRouteHostIsInheritedByInvokableControllerRoutes(): void + { + $client = static::createClient(); + $router = $client->getContainer()->get('router'); + + $route = $router->getRouteCollection()->get('host_admin_custom_invokable'); + $this->assertNotNull($route); + $this->assertSame('backend.example.com', $route->getHost()); + } + + public function testDashboardRouteHostIsInheritedByMethodRoutes(): void + { + $client = static::createClient(); + $router = $client->getContainer()->get('router'); + + $action1Route = $router->getRouteCollection()->get('host_admin_standalone_action1'); + $this->assertNotNull($action1Route); + $this->assertSame('backend.example.com', $action1Route->getHost()); + + $action2Route = $router->getRouteCollection()->get('host_admin_standalone_action2'); + $this->assertNotNull($action2Route); + $this->assertSame('backend.example.com', $action2Route->getHost()); + } + + public function testAdminRouteHostTakesPrecedenceOverDashboardHost(): void + { + $client = static::createClient(); + $router = $client->getContainer()->get('router'); + + // the host defined by the #[AdminRoute] attribute wins over the dashboard host + $route = $router->getRouteCollection()->get('host_admin_custom_host'); + $this->assertNotNull($route); + $this->assertSame('files.example.com', $route->getHost()); + + // and it is also applied to dashboards that do not define any host + $route2 = $router->getRouteCollection()->get('admin_custom_host'); + $this->assertNotNull($route2); + $this->assertSame('files.example.com', $route2->getHost()); + + // the same applies to invokable controllers + $invokableRoute = $router->getRouteCollection()->get('host_admin_custom_host_invokable'); + $this->assertNotNull($invokableRoute); + $this->assertSame('files.example.com', $invokableRoute->getHost()); + + $invokableRoute2 = $router->getRouteCollection()->get('admin_custom_host_invokable'); + $this->assertNotNull($invokableRoute2); + $this->assertSame('files.example.com', $invokableRoute2->getHost()); + } + + /** + * @see https://github.com/EasyCorp/EasyAdminBundle/issues/7119 + */ + public function testDashboardsWithDifferentHostsAreIsolated(): void + { + $client = static::createClient(); + $collection = $client->getContainer()->get('router')->getRouteCollection(); + + // the CRUD routes of a dashboard restricted to a host are only matched on that host + $matcher = new UrlMatcher($collection, new RequestContext('', 'GET', 'backend.example.com')); + $this->assertSame('host_admin_built_in_action_list', $matcher->match('/host-admin/built-in-action/index')['_route']); + + // and they must not leak to any other host + $matcher = new UrlMatcher($collection, new RequestContext('', 'GET', 'intranet.example.com')); + $this->expectException(ResourceNotFoundException::class); + $matcher->match('/host-admin/built-in-action/index'); + } + + /** + * @see https://github.com/EasyCorp/EasyAdminBundle/issues/7119 + * @see https://github.com/EasyCorp/EasyAdminBundle/issues/6756 + */ + public function testAbsoluteUrlsUseTheHostOfTheirDashboard(): void + { + $client = static::createClient(); + $collection = $client->getContainer()->get('router')->getRouteCollection(); + + // the generator is built from the route collection instead of using the 'router' + // service, so that a compiled generator in the cache cannot skew the result + $generator = new UrlGenerator($collection, new RequestContext('', 'GET', 'www.example.com')); + + $urls = [ + 'host_admin' => 'http://backend.example.com/host-admin', + 'host_admin_built_in_action_list' => 'http://backend.example.com/host-admin/built-in-action/index', + 'host_admin_standalone_action1' => 'http://backend.example.com/host-admin/standalone/action1', + ]; + + foreach ($urls as $routeName => $expectedUrl) { + $this->assertSame($expectedUrl, $generator->generate($routeName, [], UrlGeneratorInterface::ABSOLUTE_URL)); + } + } + + /** + * @see https://github.com/EasyCorp/EasyAdminBundle/issues/6756 + */ + public function testParameterizedDashboardHostIsInheritedWithItsDefaultsAndRequirements(): void + { + $client = static::createClient(); + $collection = $client->getContainer()->get('router')->getRouteCollection(); + + $routeNames = ['param_admin', 'param_admin_built_in_action_list', 'param_admin_standalone_action1']; + + foreach ($routeNames as $routeName) { + $route = $collection->get($routeName); + $this->assertNotNull($route, sprintf('Expected route "%s" not found', $routeName)); + + // the host placeholder is useless without the default and requirement that define it + $this->assertSame('{subdomain}', $route->getHost()); + $this->assertSame('admin.example.com', $route->getDefault('subdomain')); + $this->assertSame('admin.*', $route->getRequirement('subdomain')); + } + + // without the inherited default, generating these URLs throws a MissingMandatoryParametersException + $generator = new UrlGenerator($collection, new RequestContext('', 'GET', 'www.example.com')); + + $this->assertSame( + 'http://admin.example.com/param-admin/built-in-action/index', + $generator->generate('param_admin_built_in_action_list', [], UrlGeneratorInterface::ABSOLUTE_URL) + ); + $this->assertSame( + 'http://admin.example.com/param-admin/standalone/action1', + $generator->generate('param_admin_standalone_action1', [], UrlGeneratorInterface::ABSOLUTE_URL) + ); + } + + /** + * CRUD controllers are mounted on every dashboard, so a dashboard restricted to a host + * also restricts the CRUD routes generated for it. Use the 'allowedControllers' and + * 'deniedControllers' arguments of #[AdminDashboard] to control which ones are mounted. + * + * @see https://github.com/EasyCorp/EasyAdminBundle/issues/7411 + */ + public function testCrudRoutesMountedOnAHostRestrictedDashboardInheritItsHost(): void + { + $client = static::createClient(); + $collection = $client->getContainer()->get('router')->getRouteCollection(); + + $crudRouteNames = []; + foreach ($collection as $name => $route) { + if (str_starts_with($name, 'host_admin_built_in_action_')) { + $crudRouteNames[] = $name; + $this->assertSame('backend.example.com', $route->getHost(), sprintf('Route "%s" should be restricted to the dashboard host', $name)); + } + } + + $this->assertNotEmpty($crudRouteNames); + } + + public function testRoutesOfDashboardsWithoutHostAreNotRestricted(): void + { + $client = static::createClient(); + $router = $client->getContainer()->get('router'); + + $routeNames = [ + 'admin', + 'admin_built_in_action_list', + 'admin_custom_invokable', + 'admin_standalone_action1', + ]; + + foreach ($routeNames as $routeName) { + $route = $router->getRouteCollection()->get($routeName); + $this->assertNotNull($route, sprintf('Expected route "%s" not found', $routeName)); + $this->assertSame('', $route->getHost(), sprintf('Route "%s" should not be restricted to a host', $routeName)); + } + } + public function testStandaloneMethodCrudRoutes(): void { $client = static::createClient(); diff --git a/tests/Functional/Apps/AdminRouteApp/src/Controller/CustomHostController.php b/tests/Functional/Apps/AdminRouteApp/src/Controller/CustomHostController.php new file mode 100644 index 0000000000..c0c938896e --- /dev/null +++ b/tests/Functional/Apps/AdminRouteApp/src/Controller/CustomHostController.php @@ -0,0 +1,24 @@ + 'files.example.com'] + )] + public function customHost(): Response + { + return new Response('Custom Host'); + } +} diff --git a/tests/Functional/Apps/AdminRouteApp/src/Controller/CustomHostInvokableController.php b/tests/Functional/Apps/AdminRouteApp/src/Controller/CustomHostInvokableController.php new file mode 100644 index 0000000000..6ebb42c5a6 --- /dev/null +++ b/tests/Functional/Apps/AdminRouteApp/src/Controller/CustomHostInvokableController.php @@ -0,0 +1,24 @@ + 'files.example.com'] +)] +class CustomHostInvokableController extends AbstractController +{ + public function __invoke(): Response + { + return new Response('Custom Host Invokable'); + } +} diff --git a/tests/Functional/Apps/AdminRouteApp/src/Controller/HostDashboardController.php b/tests/Functional/Apps/AdminRouteApp/src/Controller/HostDashboardController.php new file mode 100644 index 0000000000..b10e3554ae --- /dev/null +++ b/tests/Functional/Apps/AdminRouteApp/src/Controller/HostDashboardController.php @@ -0,0 +1,26 @@ + 'backend.example.com', + ], +)] +class HostDashboardController extends AbstractDashboardController +{ + public function index(): Response + { + return $this->render('@EasyAdmin/page/dashboard.html.twig'); + } +} diff --git a/tests/Functional/Apps/AdminRouteApp/src/Controller/ParameterizedHostDashboardController.php b/tests/Functional/Apps/AdminRouteApp/src/Controller/ParameterizedHostDashboardController.php new file mode 100644 index 0000000000..1a63bfe427 --- /dev/null +++ b/tests/Functional/Apps/AdminRouteApp/src/Controller/ParameterizedHostDashboardController.php @@ -0,0 +1,24 @@ + '{subdomain}', + 'defaults' => ['subdomain' => 'admin.example.com'], + 'requirements' => ['subdomain' => 'admin.*'], + ], +)] +class ParameterizedHostDashboardController extends AbstractDashboardController +{ + public function index(): Response + { + return $this->render('@EasyAdmin/page/dashboard.html.twig'); + } +}