Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions src/Router/AdminRouteGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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']);
Expand All @@ -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'];
}
Expand Down Expand Up @@ -520,7 +536,36 @@ private function getDefaultRoutesConfig(string $dashboardFqcn): array
}

/**
* @return array<string, array{routeName: string, routePath: string}>
* 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<string, mixed> $routeOptions
*
* @return array{host: string|null, defaults: array<string, mixed>, requirements: array<string, string>}
*/
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<string, array{routeName: string, routePath: string, routeHost: string|null, routeHostDefaults: array<string, mixed>, routeHostRequirements: array<string, string>}>
*/
private function getDashboardsRouteConfig(): array
{
Expand All @@ -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, '/');
}
Expand All @@ -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;
Expand Down Expand Up @@ -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'],
];
}

Expand Down
189 changes: 189 additions & 0 deletions tests/Functional/AdminRoute/AdminRouteTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\AdminRouteApp\Controller;

use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;

/**
* Controller that defines its own route host, to test that it takes precedence
* over the host defined by the dashboard.
*/
class CustomHostController extends AbstractController
{
#[AdminRoute(
path: '/custom-host',
name: 'custom_host',
options: ['host' => 'files.example.com']
)]
public function customHost(): Response
{
return new Response('Custom Host');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace EasyCorp\Bundle\EasyAdminBundle\Tests\Functional\Apps\AdminRouteApp\Controller;

use EasyCorp\Bundle\EasyAdminBundle\Attribute\AdminRoute;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;

/**
* Invokable controller that defines its own route host, to test that it takes
* precedence over the host defined by the dashboard.
*/
#[AdminRoute(
path: '/custom-host-invokable',
name: 'custom_host_invokable',
options: ['host' => 'files.example.com']
)]
class CustomHostInvokableController extends AbstractController
{
public function __invoke(): Response
{
return new Response('Custom Host Invokable');
}
}
Loading