diff --git a/config/services.php b/config/services.php index 245f8bdf1e..e4fae73436 100644 --- a/config/services.php +++ b/config/services.php @@ -172,6 +172,8 @@ ->arg(4, service('router')) ->arg(5, service('cache.easyadmin')) ->arg(6, service(AdminRouteGenerator::class)) + ->arg(7, '%kernel.build_dir%') + ->arg(8, service(CrudControllerRegistry::class)) ->tag('kernel.event_subscriber') ->set(ControllerFactory::class) @@ -212,9 +214,13 @@ ->arg(0, tagged_iterator(EasyAdminExtension::TAG_DASHBOARD_CONTROLLER)) ->arg(1, tagged_iterator(EasyAdminExtension::TAG_CRUD_CONTROLLER)) ->arg(2, service('cache.easyadmin')) + ->arg(3, service('filesystem')) + ->arg(4, '%kernel.build_dir%') ->set(AdminRouteLoader::class) ->arg(0, service(AdminRouteGenerator::class)) + ->arg(1, service('filesystem')) + ->arg(2, '%kernel.build_dir%') ->tag('routing.loader', ['type' => AdminRouteLoader::ROUTE_LOADER_TYPE]) ->set(UrlSigner::class) diff --git a/src/EventListener/AdminRouterSubscriber.php b/src/EventListener/AdminRouterSubscriber.php index 38ca534a75..5ac2da04e0 100644 --- a/src/EventListener/AdminRouterSubscriber.php +++ b/src/EventListener/AdminRouterSubscriber.php @@ -2,15 +2,18 @@ namespace EasyCorp\Bundle\EasyAdminBundle\EventListener; +use EasyCorp\Bundle\EasyAdminBundle\Cache\CacheWarmer; use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\CrudControllerInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Controller\DashboardControllerInterface; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Router\AdminRouteGeneratorInterface; use EasyCorp\Bundle\EasyAdminBundle\Factory\AdminContextFactory; use EasyCorp\Bundle\EasyAdminBundle\Factory\ControllerFactory; +use EasyCorp\Bundle\EasyAdminBundle\Registry\CrudControllerRegistry; use EasyCorp\Bundle\EasyAdminBundle\Router\AdminRouteGenerator; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface; use Symfony\Component\HttpKernel\Event\ControllerEvent; @@ -40,8 +43,11 @@ class AdminRouterSubscriber implements EventSubscriberInterface private RequestMatcherInterface $requestMatcher; private CacheItemPoolInterface $cache; private AdminRouteGeneratorInterface $adminRouteGenerator; + private CrudControllerRegistry $crudControllerRegistry; + private string $buildDir; + private bool $requestAlreadyProcessedAsPrettyUrl = false; - public function __construct(AdminContextFactory $adminContextFactory, ControllerFactory $controllerFactory, ControllerResolverInterface $controllerResolver, UrlGeneratorInterface $urlGenerator, RequestMatcherInterface $requestMatcher, CacheItemPoolInterface $cache, AdminRouteGenerator $adminRouteGenerator) + public function __construct(AdminContextFactory $adminContextFactory, ControllerFactory $controllerFactory, ControllerResolverInterface $controllerResolver, UrlGeneratorInterface $urlGenerator, RequestMatcherInterface $requestMatcher, CacheItemPoolInterface $cache, AdminRouteGenerator $adminRouteGenerator, string $buildDir, CrudControllerRegistry $crudControllerRegistry) { $this->adminContextFactory = $adminContextFactory; $this->controllerFactory = $controllerFactory; @@ -50,6 +56,8 @@ public function __construct(AdminContextFactory $adminContextFactory, Controller $this->requestMatcher = $requestMatcher; $this->cache = $cache; $this->adminRouteGenerator = $adminRouteGenerator; + $this->buildDir = $buildDir; + $this->crudControllerRegistry = $crudControllerRegistry; } public static function getSubscribedEvents(): array @@ -67,21 +75,58 @@ public static function getSubscribedEvents(): array public function onKernelRequestPrettyUrls(RequestEvent $event): void { $request = $event->getRequest(); - if (false === $request->attributes->has(EA::ROUTE_CREATED_BY_EASYADMIN)) { - return; + if (false === $request->attributes->getBoolean(EA::ROUTE_CREATED_BY_EASYADMIN)) { + // at this point, the incoming request can be: + // (1) A dashboard URL (they don't have the EasyAdmin route attributes) of an app using pretty URLs + // (2) An ugly URL from EasyAdmin (they all use the main dashboard URL and select the action to execute via query params) + // (3) a regular Symfony request (not related to EasyAdmin) + + // check if the URL includes the 'crudControllerFqcn' query parameter + // if it does, this is case (2) and we don't handle it as a pretty URL + if ($request->query->has(EA::CRUD_CONTROLLER_FQCN)) { + return; + } + + // this can be case (1) or (3). Sadly, the dashboard routes don't include + // the EasyAdmin route attributes because they are regular Symfony routes. + // I can't find any way (inside our custom route loader, in a compiler pass, etc.) to add the + // custom EasyAdmin route defaults/attributes to other existing Symfony routes. So, we have to + // check if the route of the current request matches any of the cached dashboard routes + $dashboardRoutesCachePath = $this->buildDir.'/'.CacheWarmer::DASHBOARD_ROUTES_CACHE; + $dashboardControllerRoutes = []; + if (file_exists($dashboardRoutesCachePath)) { + try { + $dashboardControllerRoutes = require $dashboardRoutesCachePath; + } catch (\Throwable) { + } + } + + // this is not a cached dashboard route, so this is case (3) a regular Symfony request + if (!\array_key_exists($request->attributes->get('_route'), $dashboardControllerRoutes)) { + return; + } } // edge-case: in some scenarios, admin routes are generated by the custom route loader // and their information is cached but then removed from the cache (e.g. when running // 'rm -fr var/cache/* && bin/console cache:clear'). If that's the case, regenerate the - // admin routes to force saving them in the cache again. + // admin routes (only if the app uses pretty URLs) to force saving them in the cache again. // see https://github.com/EasyCorp/EasyAdminBundle/issues/6680 $adminRoutes = $this->cache->getItem(AdminRouteGenerator::CACHE_KEY_ROUTE_TO_FQCN)->get(); - if (null === $adminRoutes) { + if (null === $adminRoutes && $this->adminRouteGenerator->usesPrettyUrls()) { $this->adminRouteGenerator->generateAll(); } $dashboardControllerFqcn = $request->attributes->get(EA::DASHBOARD_CONTROLLER_FQCN); + if (null === $dashboardControllerFqcn) { + if (!isset($dashboardControllerRoutes[$request->attributes->get('_route')])) { + return; + } + + $dashboardCallableAsString = $dashboardControllerRoutes[$request->attributes->get('_route')]; + [$dashboardControllerFqcn, ] = explode('::', $dashboardCallableAsString); + } + if (null === $dashboardControllerInstance = $this->getDashboardControllerInstance($dashboardControllerFqcn, $request)) { return; } @@ -97,6 +142,7 @@ public function onKernelRequestPrettyUrls(RequestEvent $event): void } $request->attributes->set(EA::CONTEXT_REQUEST_ATTRIBUTE, $adminContext); + $this->requestAlreadyProcessedAsPrettyUrl = true; } /** @@ -105,11 +151,34 @@ public function onKernelRequestPrettyUrls(RequestEvent $event): void */ public function onKernelRequest(RequestEvent $event): void { + if ($this->requestAlreadyProcessedAsPrettyUrl) { + return; + } + + // return early if this is not a URL associated with EasyAdmin $request = $event->getRequest(); if (null === $dashboardControllerFqcn = $this->getDashboardControllerFqcn($request)) { return; } + // if this is a ugly URL from legacy EasyAdmin versions and the application + // uses pretty URLs, redirect to the equivalent pretty URL + if ($this->adminRouteGenerator->usesPrettyUrls()) { + $entityFqcnOrCrudControllerFqcn = $request->query->get(EA::CRUD_CONTROLLER_FQCN); + if (is_subclass_of($entityFqcnOrCrudControllerFqcn, CrudControllerInterface::class)) { + $crudControllerFqcn = $entityFqcnOrCrudControllerFqcn; + } else { + $crudControllerFqcn = $this->crudControllerRegistry->findCrudFqcnByEntityFqcn($entityFqcnOrCrudControllerFqcn); + } + + $prettyUrlRoute = $this->adminRouteGenerator->findRouteName($dashboardControllerFqcn, $crudControllerFqcn, $request->query->get(EA::CRUD_ACTION, '')); + $request->query->remove(EA::CRUD_CONTROLLER_FQCN); + + $event->setResponse(new RedirectResponse($this->urlGenerator->generate($prettyUrlRoute, $request->query->all()))); + + return; + } + if (null === $dashboardControllerInstance = $this->getDashboardControllerInstance($dashboardControllerFqcn, $request)) { return; } diff --git a/src/Router/AdminRouteGenerator.php b/src/Router/AdminRouteGenerator.php index 094ad95d90..cf639eb63a 100644 --- a/src/Router/AdminRouteGenerator.php +++ b/src/Router/AdminRouteGenerator.php @@ -8,6 +8,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Config\Option\EA; use EasyCorp\Bundle\EasyAdminBundle\Contracts\Router\AdminRouteGeneratorInterface; use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -59,10 +60,14 @@ final class AdminRouteGenerator implements AdminRouteGeneratorInterface ], ]; + private ?bool $applicationUsesPrettyUrls = null; + public function __construct( private iterable $dashboardControllers, private iterable $crudControllers, private CacheItemPoolInterface $cache, + private Filesystem $filesystem, + private string $buildDir, ) { } @@ -86,9 +91,7 @@ public function generateAll(): RouteCollection // TODO: remove this method in EasyAdmin 5.x public function usesPrettyUrls(): bool { - $cachedAdminRoutes = $this->cache->getItem(self::CACHE_KEY_FQCN_TO_ROUTE)->get(); - - return null !== $cachedAdminRoutes && [] !== $cachedAdminRoutes; + return $this->applicationUsesPrettyUrls ??= $this->filesystem->exists(sprintf('%s/%s', $this->buildDir, AdminRouteLoader::PRETTY_URLS_CONTEXT_FILE_NAME)); } public function findRouteName(string $dashboardFqcn, string $crudControllerFqcn, string $actionName): ?string diff --git a/src/Router/AdminRouteLoader.php b/src/Router/AdminRouteLoader.php index 572bc8bd04..2997cb38c6 100644 --- a/src/Router/AdminRouteLoader.php +++ b/src/Router/AdminRouteLoader.php @@ -4,6 +4,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Contracts\Router\AdminRouteGeneratorInterface; use Symfony\Component\Config\Loader\Loader; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Routing\RouteCollection; /** @@ -12,9 +13,13 @@ final class AdminRouteLoader extends Loader { public const ROUTE_LOADER_TYPE = 'easyadmin.routes'; + /** @internal don't use this in your application */ + public const PRETTY_URLS_CONTEXT_FILE_NAME = 'easyadmin/application_uses_pretty_urls.txt'; public function __construct( private AdminRouteGeneratorInterface $adminRouteGenerator, + private Filesystem $filesystem, + private string $buildDir, ) { parent::__construct(null); } @@ -26,6 +31,24 @@ public function supports($resource, ?string $type = null): bool public function load($resource, ?string $type = null): RouteCollection { + // this is ugly, but I can't find any other way of solving this problem. + // Details about the problem to solve: EasyAdmin must support both ugly and + // pretty URLs and the user must not configure anything to enable pretty URLs. + // In som parts of this bundle, we need to know if the custom route loader was + // run to decide if pretty URLs should be used or not. + // + // This can't be solved in any of these ways: + // * the router doesn't provide any feature to know if a route loader was run + // * we can't set a container parameter dynamically because ParameterBag is frozen + // * we can't store something in the cache because it's not reliable (see https://github.com/symfony/symfony/issues/59445) + // * we can't create a "marker service" set inside the custom route loader because + // this only works when the route loader is run the first time (next times, the routes are cached) + // * etc. + // + // The only reliable way is to create a "marker file" in the cache that is not deleted. + // All this will be greatly simplified in EasyAdmin 5.x when pretty URLs will be mandatory. + $this->filesystem->dumpFile(sprintf('%s/%s', $this->buildDir, self::PRETTY_URLS_CONTEXT_FILE_NAME), ''); + return $this->adminRouteGenerator->generateAll(); } } diff --git a/templates/layout.html.twig b/templates/layout.html.twig index 9525480d14..bf55cb059e 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -249,7 +249,9 @@ {% block search %} {% set formActionUrl = null %} {% if ea.usePrettyUrls %} - {% set formActionUrl = ea_url().setController(ea.request.attributes.get('crudControllerFqcn')).setAction('index').set('page', 1) %} + {# even if the app uses pretty URLs, the user might be using an ugly URL, so the controller might be defined in the query string #} + {% set crudController = ea.request.attributes.get('crudControllerFqcn') ?? ea.request.query.get('crudControllerFqcn') %} + {% set formActionUrl = ea_url().setController(crudController).setAction('index').set('page', 1) %} {% endif %}