From 2663cbbf0779625a0c90fce3d77a6f8cf30af8ca Mon Sep 17 00:00:00 2001
From: Adam G-H <32250-phenaproxima@users.noreply.drupalcode.org>
Date: Thu, 27 Jun 2024 17:43:40 +0000
Subject: [PATCH] Issue #3457376 by phenaproxima, tim.plunkett,
 chrisfromredfin: Use a route enhancer to move away from having project ID
 hashes in URLs

---
 .../ProjectBrowserSource/RandomDataPlugin.php |   1 +
 project_browser.routing.yml                   |   3 +-
 project_browser.services.yml                  |   4 +
 src/Controller/BrowserController.php          |  10 +-
 src/Controller/InstallerController.php        |  52 +++++------
 src/EnabledSourceHandler.php                  |  12 +--
 .../ProjectBrowserSource/DrupalCore.php       |   3 +-
 .../ProjectBrowserSource/MockDrupalDotOrg.php |   1 +
 src/ProjectBrowser/Project.php                |  19 ++++
 src/Routing/ProjectBrowserRoutes.php          |  40 +++++++-
 src/Routing/ProjectEnhancer.php               |  61 ++++++++++++
 sveltejs/public/build/bundle.js               | Bin 452824 -> 452696 bytes
 sveltejs/public/build/bundle.js.map           | Bin 310149 -> 310101 bytes
 sveltejs/src/App.svelte                       |   5 +-
 .../Functional/InstallerControllerTest.php    |  87 +++++++-----------
 .../ProjectBrowserUiTest.php                  |   3 +-
 16 files changed, 199 insertions(+), 102 deletions(-)
 create mode 100644 src/Routing/ProjectEnhancer.php

diff --git a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
index 7abcf406f..82fb36148 100644
--- a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
+++ b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
@@ -200,6 +200,7 @@ class RandomDataPlugin extends ProjectBrowserSourceBase {
         packageName: 'random/' . $machine_name,
         categories: [$categories[array_rand($categories)]],
         images: $project_images,
+        id: $machine_name,
       );
     }
     $this->cacheBin->set('RandomData:projects', $projects);
diff --git a/project_browser.routing.yml b/project_browser.routing.yml
index 5c15191d0..b7ad1762b 100644
--- a/project_browser.routing.yml
+++ b/project_browser.routing.yml
@@ -19,11 +19,12 @@ project_browser.api_project_get_all:
   #options:
   #  no_cache: 'TRUE'
 project_browser.browse:
-  path: '/admin/modules/browse/{id}'
+  path: '/admin/modules/browse/{source}/{id}'
   defaults:
     _controller: '\Drupal\project_browser\Controller\BrowserController::browse'
     _title: 'Browse projects'
     id: null
+    source: null
   requirements:
     _permission: 'administer modules'
 project_browser.settings:
diff --git a/project_browser.services.yml b/project_browser.services.yml
index fec72d8eb..7152710c4 100644
--- a/project_browser.services.yml
+++ b/project_browser.services.yml
@@ -35,3 +35,7 @@ services:
     public: false
     tags:
       - { name: project_browser.activator }
+  Drupal\project_browser\Routing\ProjectEnhancer:
+    public: false
+    tags:
+      - { name: route_enhancer }
diff --git a/src/Controller/BrowserController.php b/src/Controller/BrowserController.php
index da1d0fbcd..d19796c2d 100644
--- a/src/Controller/BrowserController.php
+++ b/src/Controller/BrowserController.php
@@ -63,18 +63,22 @@ class BrowserController extends ControllerBase {
    * rendered. For example, 'https//drupal-site/admin/modules/browse/ctools'
    * will display the details for ctools.
    *
+   * @param string|null $source
+   *   (optional) If viewing a specific project, the ID of the source plugin
+   *   that exposed it.
    * @param string|null $id
-   *   The project ID, if any.
+   *   (optional) If viewing a specific project, the project's local ID (as
+   *   known to the source plugin).
    *
    * @return array
    *   A render array.
    */
-  public function browse(?string $id = NULL) {
+  public function browse(?string $source, ?string $id) {
     $request = $this->requestStack->getCurrentRequest();
     $current_sources = $this->enabledSource->getCurrentSources();
     $ui_install_enabled = (bool) $this->config('project_browser.admin_settings')->get('allow_ui_install') && (bool) $this->installReadiness;
 
-    if (array_key_exists('drupalorg_mockapi', $current_sources) && empty($id)) {
+    if (array_key_exists('drupalorg_mockapi', $current_sources) && (empty($source) || empty($id))) {
       $this->messenger()
         ->addStatus($this->t('Project Browser is currently a prototype, and the projects listed may not be up to date with Drupal.org. For the most updated list of projects, visit <a href=":url">:url</a>', [':url' => 'https://www.drupal.org/project/project_module']))
         ->addStatus($this->t('Your feedback and input are welcome at <a href=":url">:url</a>', [':url' => 'https://www.drupal.org/project/issues/project_browser']));
diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 7020569ed..7209ad0e5 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -13,6 +13,7 @@ use Drupal\package_manager\Exception\StageException;
 use Drupal\project_browser\ActivatorInterface;
 use Drupal\project_browser\ComposerInstaller\Installer;
 use Drupal\project_browser\EnabledSourceHandler;
+use Drupal\project_browser\ProjectBrowser\Project;
 use Psr\Log\LoggerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -141,8 +142,8 @@ class InstallerController extends ControllerBase {
   /**
    * Returns the status of the project in the temp store.
    *
-   * @param string $uuid
-   *   The UUID of the project, as known to the enabled sources handler.
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project whose status to report.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Information about the project's require/install status.
@@ -154,16 +155,16 @@ class InstallerController extends ControllerBase {
    *   regularly so it can monitor the progress of the process and report which
    *   stage is taking place.
    */
-  public function inProgress(string $uuid): JsonResponse {
+  public function inProgress(Project $project): JsonResponse {
     $requiring = $this->projectBrowserTempStore->get('requiring');
     $core_installing = $this->projectBrowserTempStore->get('installing');
     $return = ['status' => self::STATUS_IDLE];
 
-    if (isset($requiring['project_id']) && $requiring['project_id'] === $uuid) {
+    if (isset($requiring['project_id']) && $requiring['project_id'] === $project->id) {
       $return['status'] = self::STATUS_REQUIRING_PROJECT;
       $return['phase'] = $requiring['phase'];
     }
-    if ($core_installing === $uuid) {
+    if ($core_installing === $project->id) {
       $return['status'] = self::STATUS_INSTALLING_PROJECT;
     }
 
@@ -243,18 +244,18 @@ class InstallerController extends ControllerBase {
   /**
    * Updates the 'requiring' state in the temp store.
    *
-   * @param string $uuid
-   *   The UUID of the project being required, as known to the enabled sources
+   * @param string $id
+   *   The ID of the project being required, as known to the enabled sources
    *   handler.
    * @param string $phase
    *   The require phase in progress.
    * @param string|null $stage_id
    *   The stage ID, if known.
    */
-  private function setRequiringState(?string $uuid, string $phase, ?string $stage_id): void {
+  private function setRequiringState(?string $id, string $phase, ?string $stage_id): void {
     $data = $this->projectBrowserTempStore->get('requiring') ?? [];
-    if ($uuid) {
-      $data['project_id'] = $uuid;
+    if ($id) {
+      $data['project_id'] = $id;
     }
     if ($stage_id) {
       $data['stage_id'] = $stage_id;
@@ -329,18 +330,17 @@ class InstallerController extends ControllerBase {
   /**
    * Begins requiring by creating a stage.
    *
-   * @param string $uuid
-   *   The UUID of the project, as known to the enabled sources handler.
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   The project we're about to require.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function begin(string $uuid): JsonResponse {
-    $project = $this->enabledSourceHandler->getStoredProject($uuid);
+  public function begin(Project $project): JsonResponse {
     // @todo Expand to support other plugins in https://drupal.org/i/3312354.
     $source = $this->enabledSourceHandler->getCurrentSources()['drupalorg_mockapi'] ?? NULL;
     if ($source === NULL) {
-      return new JsonResponse(['message' => "Cannot download $uuid from any available source"], 500);
+      return new JsonResponse(['message' => "Cannot download $project->id from any available source"], 500);
     }
     if (!$source->isProjectSafe($project)) {
       return new JsonResponse(['message' => "$project->machineName is not safe to add because its security coverage has been revoked"], 500);
@@ -408,24 +408,24 @@ class InstallerController extends ControllerBase {
   /**
    * Performs require operations on the stage.
    *
-   * @param string $uuid
-   *   The UUID of the project, as known to the enabled sources handler.
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   The project to be required.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function require(string $uuid): JsonResponse {
+  public function require(Project $project): JsonResponse {
     $requiring = $this->projectBrowserTempStore->get('requiring');
-    if (empty($requiring['project_id']) || $requiring['project_id'] !== $uuid) {
+    if (empty($requiring['project_id']) || $requiring['project_id'] !== $project->id) {
       return new JsonResponse([
-        'message' => sprintf('Error: a request to install %s was ignored as an install for a different module is in progress.', $uuid),
+        'message' => sprintf('Error: a request to install %s was ignored as an install for a different module is in progress.', $project->id),
       ], 500);
     }
     $this->setRequiringState(NULL, 'requiring module', NULL);
 
     try {
       $this->installer->claim($requiring['stage_id'])->require([
-        $this->enabledSourceHandler->getStoredProject($uuid)->packageName,
+        $project->packageName,
       ]);
       return $this->successResponse('require', $requiring['stage_id']);
     }
@@ -498,17 +498,17 @@ class InstallerController extends ControllerBase {
   /**
    * Installs an already downloaded module.
    *
-   * @param string $uuid
-   *   The UUID of the project, as known to the enabled sources handler.
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   The project to activate.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function activate(string $uuid): JsonResponse {
-    $this->projectBrowserTempStore->set('installing', $uuid);
+  public function activate(Project $project): JsonResponse {
+    $this->projectBrowserTempStore->set('installing', $project->id);
 
     try {
-      $this->activator->activate($this->enabledSourceHandler->getStoredProject($uuid));
+      $this->activator->activate($project);
     }
     catch (\Throwable $e) {
       return $this->errorResponse($e, 'project install');
diff --git a/src/EnabledSourceHandler.php b/src/EnabledSourceHandler.php
index 2a56fac20..4a242d199 100644
--- a/src/EnabledSourceHandler.php
+++ b/src/EnabledSourceHandler.php
@@ -115,13 +115,9 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
       $stored = [];
       foreach ($projects as $source_id => $results) {
         foreach ($results->list as $project) {
-          // Generate an ID for the project from the package name and machine
-          // name, which are unlikely to change. This isn't security-sensitive,
-          // so SHA1 is okay for this purpose.
-          $project->id = sha1($source_id . $project->packageName . $project->machineName);
-          // Remember the ID of the source plugin that exposed this project,
-          // since that information might be needed by the front-end.
-          $project->source = $source_id;
+          // Prefix the local project ID with the source plugin ID, so we can
+          // look it up unambiguously.
+          $project->id = $source_id . '/' . $project->id;
 
           $this->keyValue->setIfNotExists($project->id, $project);
           // Add activation data to the project. This is volatile and should not
@@ -223,6 +219,8 @@ class EnabledSourceHandler implements LoggerAwareInterface, EventSubscriberInter
     $project->status = $this->activator->getStatus($project);
     // The activator is responsible for generating the instructions.
     $project->commands = $this->activator->getInstructions($project);
+    // Give the front-end the ID of the source plugin that exposed this project.
+    [$project->source] = explode('/', $project->id, 2);
   }
 
 }
diff --git a/src/Plugin/ProjectBrowserSource/DrupalCore.php b/src/Plugin/ProjectBrowserSource/DrupalCore.php
index a839ac053..aea3c5369 100644
--- a/src/Plugin/ProjectBrowserSource/DrupalCore.php
+++ b/src/Plugin/ProjectBrowserSource/DrupalCore.php
@@ -205,13 +205,14 @@ class DrupalCore extends ProjectBrowserSourceBase {
         author: [
           'name' => 'Drupal Core',
         ],
-        packageName: '',
+        packageName: 'drupal/core',
         categories: [
           [
             'id' => $module->info['package'],
             'name' => $module->info['package'],
           ],
         ],
+        id: $module_name,
       );
     }
 
diff --git a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
index 3fe3d3c58..12df3f57e 100644
--- a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
+++ b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
@@ -397,6 +397,7 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase {
           categories: array_map(fn($category) => $categories[$category['id']] ?? '', $project_data['project_data']['taxonomy_vocabulary_3'] ?? []),
           images: $project_data['project_data']['field_project_images'] ?? [],
           warnings: $this->getWarnings($project_data),
+          id: $project_data['field_project_machine_name'],
         );
       }
     }
diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php
index f861e4ed3..57d83c74d 100644
--- a/src/ProjectBrowser/Project.php
+++ b/src/ProjectBrowser/Project.php
@@ -105,6 +105,12 @@ class Project implements \JsonSerializable {
    * @param string|ProjectType $type
    *   The project type. Defaults to a module, but may be any string that is not
    *   one of the cases of \Drupal\project_browser\ProjectType.
+   * @param string|null $id
+   *   (optional) A local, source plugin-specific identifier for this project.
+   *   Cannot contain slashes. Will be automatically generated if not passed.
+   *
+   * @throws \InvalidArgumentException
+   *   Thrown if $id contains slashes.
    */
   public function __construct(
     public array $logo,
@@ -126,6 +132,7 @@ class Project implements \JsonSerializable {
     public array $images = [],
     public array $warnings = [],
     string|ProjectType $type = ProjectType::Module,
+    ?string $id = NULL,
   ) {
     $this->setSummary($body);
 
@@ -134,6 +141,18 @@ class Project implements \JsonSerializable {
       $type = ProjectType::tryFrom($type) ?? $type;
     }
     $this->type = $type;
+
+    // If no local ID was passed, generate it from the package name and machine
+    // name, which are unlikely to change.
+    if (empty($id)) {
+      $id = str_replace('/', '-', [$packageName, $machineName]);
+      $id = implode('-', $id);
+      $id = trim($id, '-');
+    }
+    if (str_contains($id, '/')) {
+      throw new \InvalidArgumentException("The project ID cannot contain slashes.");
+    }
+    $this->id = $id;
   }
 
   /**
diff --git a/src/Routing/ProjectBrowserRoutes.php b/src/Routing/ProjectBrowserRoutes.php
index fd8939c2b..e5dc80ba8 100644
--- a/src/Routing/ProjectBrowserRoutes.php
+++ b/src/Routing/ProjectBrowserRoutes.php
@@ -47,26 +47,42 @@ class ProjectBrowserRoutes implements ContainerInjectionInterface {
     }
     $routes = [];
     $routes['project_browser.stage.begin'] = new Route(
-      '/admin/modules/project_browser/install-begin/{uuid}',
+      '/admin/modules/project_browser/install-begin/{source}/{id}',
       [
         '_controller' => InstallerController::class . '::begin',
         '_title' => 'Create phase',
+        'project' => NULL,
       ],
       [
         '_permission' => 'administer modules',
         '_custom_access' => InstallerController::class . '::access',
       ],
+      [
+        'parameters' => [
+          'project' => [
+            'project_browser.project' => ['source', 'id'],
+          ],
+        ],
+      ],
     );
     $routes['project_browser.stage.require'] = new Route(
-      '/admin/modules/project_browser/install-require/{uuid}',
+      '/admin/modules/project_browser/install-require/{source}/{id}',
       [
         '_controller' => InstallerController::class . '::require',
         '_title' => 'Require phase',
+        'project' => NULL,
       ],
       [
         '_permission' => 'administer modules',
         '_custom_access' => InstallerController::class . '::access',
       ],
+      [
+        'parameters' => [
+          'project' => [
+            'project_browser.project' => ['source', 'id'],
+          ],
+        ],
+      ]
     );
     $routes['project_browser.stage.apply'] = new Route(
       '/admin/modules/project_browser/install-apply',
@@ -102,26 +118,42 @@ class ProjectBrowserRoutes implements ContainerInjectionInterface {
       ],
     );
     $routes['project_browser.activate'] = new Route(
-      '/admin/modules/project_browser/activate/{uuid}',
+      '/admin/modules/project_browser/activate/{source}/{id}',
       [
         '_controller' => InstallerController::class . '::activate',
         '_title' => 'Install module in core',
+        'project' => NULL,
       ],
       [
         '_permission' => 'administer modules',
         '_custom_access' => InstallerController::class . '::access',
       ],
+      [
+        'parameters' => [
+          'project' => [
+            'project_browser.project' => ['source', 'id'],
+          ],
+        ],
+      ]
     );
     $routes['project_browser.module.install_in_progress'] = new Route(
-      '/admin/modules/project_browser/install_in_progress/{uuid}',
+      '/admin/modules/project_browser/install_in_progress/{source}/{id}',
       [
         '_controller' => InstallerController::class . '::inProgress',
         '_title' => 'Install in progress',
+        'project' => NULL,
       ],
       [
         '_permission' => 'administer modules',
         '_custom_access' => InstallerController::class . '::access',
       ],
+      [
+        'parameters' => [
+          'project' => [
+            'project_browser.project' => ['source', 'id'],
+          ],
+        ],
+      ]
     );
     $routes['project_browser.install.unlock'] = new Route(
       '/admin/modules/project_browser/install/unlock',
diff --git a/src/Routing/ProjectEnhancer.php b/src/Routing/ProjectEnhancer.php
new file mode 100644
index 000000000..4cc95f0a2
--- /dev/null
+++ b/src/Routing/ProjectEnhancer.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\Routing;
+
+use Drupal\Core\Routing\EnhancerInterface;
+use Drupal\Core\Routing\RouteObjectInterface;
+use Drupal\project_browser\EnabledSourceHandler;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Converts route parameters into a Project object.
+ *
+ * If a route parameter defines a `project_browser.project` option, this will
+ * add a Project object to the defaults, getting the source plugin ID and local
+ * (source-specific) project ID from other route parameters.
+ *
+ * For example, consider a route like this:
+ * ```
+ * path: '/projects/view/{source}/{id}'
+ * defaults:
+ *   project: null
+ * options:
+ *   parameters:
+ *     project:
+ *       project_browser.project: [source, id]
+ * ```
+ * This will look up a project using the `source` parameter as the source plugin
+ * ID, and the `id` parameter as the project's local ID. The project will be
+ * passed to the controller's $project parameter.
+ */
+final class ProjectEnhancer implements EnhancerInterface {
+
+  public function __construct(
+    private readonly EnabledSourceHandler $enabledSources,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enhance(array $defaults, Request $request): array {
+    /** @var \Symfony\Component\Routing\Route $route */
+    $route = $defaults[RouteObjectInterface::ROUTE_OBJECT];
+
+    $parameters = $route->getOption('parameters');
+    foreach (array_keys($defaults) as $name) {
+      if (isset($parameters[$name]['project_browser.project'])) {
+        [$source_id, $local_id] = $parameters[$name]['project_browser.project'];
+
+        if (array_key_exists($source_id, $defaults) && array_key_exists($local_id, $defaults)) {
+          // @see \Drupal\project_browser\EnabledSourceHandler::getProjects()
+          $id = $defaults[$source_id] . '/' . $defaults[$local_id];
+          $defaults[$name] = $this->enabledSources->getStoredProject($id);
+        }
+      }
+    }
+    return $defaults;
+  }
+
+}
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index cb9f528c49d6673100aae26249b18ef014e4a50a..2b56daeab0ad74334cc8e4da0b00a122694421e3 100644
GIT binary patch
delta 253
zcmV<Z00RHm(Hq#%8-RoXgaU*Egam{Iv<7Fd12;M_m+<HVD3^9y1`(I)djuV~z^?}A
z1p_xaFqa@01VWcv1p^DW__YR=1D6ja0}7Xyxd!hFVPkY@c4bsyVj!2HEddsn9Eb!9
zx4*gussWdGyaq)9GPk|F2DAtPGM94222GctnF1@f>cs|i0|7OcV95qW0W-Iz$p*p$
z0W+6r%?42cIJdja1`Y-RIG4)P22cVsIkz0t24w>RGdY(#sRCZN;nxPX2LUdZo8Jap
zw+7$_t^=3I;|5ZfkFo;?mo4N5)0a<Y1P7Nd-vb+$$>0VJm%ZQv9hXmM1P8Z;<pxs&
DUJ_nW

delta 327
zcmca{LHfo;>4p}@7N!>F7M2#)Eo|xQnJuggrhj<BtTDYTnoV%}t7;b6?FZJgy<i1P
zC`hn4F&Ud}f4`M&9y6oa^uC>JKO~A%6N{2Ff=h}r^U`$`5|c|Z%ThxUlN6>e)MXT#
zF4Muny?x^@wpEP0C<>=X?O}6cG~T{&58D<FM&s!~T6lWqQ8p`PGb<CY3$~v-%2vt@
z=7Q}tF`4drmQ8&6C38l(?fXx%^)fSpMFJTux6ePz#>d8JIUPugPv2w9V##D;u<Z(4
zD)aQUj~F?oJFaAk*nadn+ctKV=s5k@={5J+Vy6F`33S<&`)r$-r!Q?}5}CeU66oRS
zkJ%hqQW8rNr(bx?_Gr439GlVfe^1!hr|aKmmYzQQ0UPi1y$_gWr~9X~uy1F5$`;HF
E0Q#?aPyhe`

diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index aa1574549a7b322482df69a1a5d9b7d3642cb6b2..278fb3a1cdfc0afd8df9d95b8f99126679db8852 100644
GIT binary patch
delta 123
zcmZquE_C&qP(usj7N(!`r`LHi$xZj&#K<*We*sesqu%uO3z%ZtB^NRQG4poGg)E1K
zS+blmT&6RAU>2P=gPC`_UoA7wcD}7F8<`l5rytnPVkVmI?C6;8tOFz+!GyJ^qpRt3
Y?j0=Y+Z%VVlrk|pI-5@avx}t@0N?K_wg3PC

delta 182
zcmccmO{n#|P(usj7N(!`d5cpMi;^>fONuh{(sicSdNavS_ua(EHGSp;Cgte|mIGOS
z3z+;_qT}>qr_Wo!6w8v5Sd!TOX#o=uGjIR2faQ!ZW5)LVTUZ`3@tQgX==eK2`s=tm
zI=W9cRAmvJzP*n{P}14dStr-o(J|Lq2S_@C32RSBSN-X4x3i@4c{)0JLd1d{oxP^_
a>$8Y%&)C6|$i!xCZSCl6KK;xtmQDb4hCj0a

diff --git a/sveltejs/src/App.svelte b/sveltejs/src/App.svelte
index 6bcf837da..04f69fd20 100644
--- a/sveltejs/src/App.svelte
+++ b/sveltejs/src/App.svelte
@@ -2,16 +2,15 @@
   import ProjectBrowser from './ProjectBrowser.svelte';
   import ModulePage from './ModulePage.svelte';
   import Loading from './Loading.svelte';
-  import { searchString, activeTab } from './stores';
+  import { activeTab } from './stores';
   import { ORIGIN_URL } from './constants';
 
   const matches = window.location.pathname.match(
-    /\/admin\/modules\/browse\/([^/]+)/,
+    /\/admin\/modules\/browse\/(.+)/,
   );
   const projectId = matches ? matches[1] : null;
 
   let loading = true;
-  let data;
   let project = [];
   let projectExists = false;
   async function load(url) {
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index e3276054a..fcb0d42a8 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -180,6 +180,9 @@ class InstallerControllerTest extends BrowserTestBase {
       ->set('enabled_sources', ['drupalorg_mockapi', 'drupal_core'])
       ->set('allow_ui_install', TRUE)
       ->save();
+
+    // Prime the non-volatile cache.
+    $this->container->get(EnabledSourceHandler::class)->getProjects();
   }
 
   /**
@@ -189,7 +192,7 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   public function testUiInstallUnavailableIfDisabled() {
     $this->config('project_browser.admin_settings')->set('allow_ui_install', FALSE)->save();
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('awesome_module'));
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(403);
     $this->assertSession()->pageTextContains('Access denied');
   }
@@ -201,7 +204,7 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   public function testInstallSecurityRevokedModule() {
     $this->assertProjectBrowserTempStatus(NULL, NULL);
-    $content = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('security_revoked_module'));
+    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/security_revoked_module');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"security_revoked_module is not safe to add because its security coverage has been revoked"}', $content);
   }
@@ -216,10 +219,9 @@ class InstallerControllerTest extends BrowserTestBase {
     // Though core is not available as a choice in project browser, it works
     // well for the purposes of this test as it's definitely already added
     // via composer.
-    $project_id = $this->getProjectUuid('core');
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id);
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/core');
     $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0];
-    $content = $this->drupalGet("/admin/modules/project_browser/install-require/$project_id");
+    $content = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/core");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: The following package is already installed: drupal\/core\n","phase":"require"}', $content);
   }
@@ -231,13 +233,12 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   private function doStart() {
     $this->assertProjectBrowserTempStatus(NULL, NULL);
-    $project_id = $this->getProjectUuid('awesome_module');
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id);
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0];
     $this->assertSession()->statusCodeEquals(200);
     $expected_output = sprintf('{"phase":"create","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress($project_id, 'creating install stage');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'creating install stage');
   }
 
   /**
@@ -246,11 +247,10 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::require
    */
   private function doRequire() {
-    $project_id = $this->getProjectUuid('awesome_module');
-    $this->drupalGet("/admin/modules/project_browser/install-require/$project_id");
+    $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module");
     $expected_output = sprintf('{"phase":"require","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress($project_id, 'requiring module');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'requiring module');
   }
 
   /**
@@ -262,7 +262,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->drupalGet("/admin/modules/project_browser/install-apply");
     $expected_output = sprintf('{"phase":"apply","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress($this->getProjectUuid('awesome_module'), 'applying');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'applying');
   }
 
   /**
@@ -274,7 +274,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->drupalGet("/admin/modules/project_browser/install-post_apply");
     $expected_output = sprintf('{"phase":"post apply","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress($this->getProjectUuid('awesome_module'), 'post apply');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'post apply');
   }
 
   /**
@@ -309,7 +309,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $message = t('This is a PreCreate error.');
     $result = ValidationResult::createError([$message]);
     TestSubscriber::setTestResult([$result], PreCreateEvent::class);
-    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('awesome_module'));
+    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: This is a PreCreate error.\n","phase":"create"}', $contents);
   }
@@ -322,7 +322,7 @@ class InstallerControllerTest extends BrowserTestBase {
   public function testPreCreateException() {
     $error = new \Exception('PreCreate did not go well.');
     TestSubscriber::setException($error, PreCreateEvent::class);
-    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('awesome_module'));
+    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PreCreate did not go well.","phase":"create"}', $contents);
   }
@@ -335,7 +335,7 @@ class InstallerControllerTest extends BrowserTestBase {
   public function testPostCreateException() {
     $error = new \Exception('PostCreate did not go well.');
     TestSubscriber::setException($error, PostCreateEvent::class);
-    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('awesome_module'));
+    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PostCreate did not go well.","phase":"create"}', $contents);
   }
@@ -350,7 +350,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $result = ValidationResult::createError([$message]);
     $this->doStart();
     TestSubscriber::setTestResult([$result], PreRequireEvent::class);
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/" . $this->getProjectUuid('awesome_module'));
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: This is a PreRequire error.\n","phase":"require"}', $contents);
   }
@@ -364,7 +364,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $error = new \Exception('PreRequire did not go well.');
     TestSubscriber::setException($error, PreRequireEvent::class);
     $this->doStart();
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/" . $this->getProjectUuid('awesome_module'));
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PreRequire did not go well.","phase":"require"}', $contents);
   }
@@ -378,7 +378,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $error = new \Exception('PostRequire did not go well.');
     TestSubscriber::setException($error, PostRequireEvent::class);
     $this->doStart();
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/" . $this->getProjectUuid('awesome_module'));
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PostRequire did not go well.","phase":"require"}', $contents);
   }
@@ -437,40 +437,39 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   public function testInstallUnlockMessage() {
     $this->doStart();
-    $project_id = $this->getProjectUuid('awesome_module');
 
     // Check for mid install unlock offer message.
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id);
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked less than 1 minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress($project_id, 'creating install stage');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'creating install stage');
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
     TestTime::setFakeTimeByOffset("+800 seconds");
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id);
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent());
     $this->doRequire();
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id);
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
     $this->doApply();
     TestTime::setFakeTimeByOffset('+800 seconds');
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id);
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertTrue($this->installer->isApplying());
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent());
     TestTime::setFakeTimeByOffset("+55 minutes");
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id);
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 55 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent());
     // Unlocking the stage becomes possible after 1 hour regardless of source.
     TestTime::setFakeTimeByOffset("+75 minutes");
-    $this->drupalGet('admin/modules/project_browser/install-begin/' . $project_id);
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 1 hours, 15 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent());
   }
@@ -486,7 +485,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->doStart();
     // Try beginning another install while one is in progress, but not yet in
     // the applying stage.
-    $content = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('metatag'));
+    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
@@ -510,7 +509,7 @@ class InstallerControllerTest extends BrowserTestBase {
   public function testCanBreakStageWithMissingProjectBrowserLock() {
     $this->doStart();
     $this->sharedTempStore->get('project_browser')->delete('requiring');
-    $content = $this->drupalGet('admin/modules/project_browser/install-begin/' . $this->getProjectUuid('metatag'));
+    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
@@ -538,7 +537,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $assert_session->checkboxNotChecked('edit-modules-views-enable');
     $assert_session->checkboxNotChecked('edit-modules-views-ui-enable');
 
-    $content = $this->drupalGet('admin/modules/project_browser/activate/' . $this->getProjectUuid('views_ui'));
+    $content = $this->drupalGet('admin/modules/project_browser/activate/drupal_core/views_ui');
     $this->assertSame('{"status":0}', $content);
     $this->rebuildContainer();
     $this->drupalGet('admin/modules');
@@ -554,9 +553,9 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   protected function assertInstallNotInProgress($module) {
     $this->assertProjectBrowserTempStatus(NULL, NULL);
-    $this->drupalGet("/admin/modules/project_browser/install_in_progress/" . $this->getProjectUuid($module));
+    $this->drupalGet("/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/$module");
     $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent());
-    $this->drupalGet('/admin/modules/project_browser/install_in_progress/' . $this->getProjectUuid('metatag'));
+    $this->drupalGet('/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/metatag');
     $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent());
   }
 
@@ -579,7 +578,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->assertProjectBrowserTempStatus($expect_install, NULL);
     $this->drupalGet("/admin/modules/project_browser/install_in_progress/$project_id");
     $this->assertSame(sprintf('{"status":1,"phase":"%s"}', $phase), $this->getSession()->getPage()->getContent());
-    $this->drupalGet('/admin/modules/project_browser/install_in_progress/' . $this->getProjectUuid('metatag'));
+    $this->drupalGet('/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/metatag');
     $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent());
   }
 
@@ -610,26 +609,4 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->assertSame($expected_installing, $project_browser_installing);
   }
 
-  /**
-   * Looks up a project UUID by module name (and optional package name).
-   *
-   * @param string $module_name
-   *   The name of the module whose project to find.
-   *
-   * @return string
-   *   The project's UUID.
-   */
-  private function getProjectUuid(string $module_name): string {
-    $projects = $this->container->get(EnabledSourceHandler::class)
-      ->getProjects();
-    foreach ($projects as $results_page) {
-      foreach ($results_page->list as $project) {
-        if ($project->machineName === $module_name) {
-          return $project->id;
-        }
-      }
-    }
-    $this->fail("There is no project for module '$module_name'.");
-  }
-
 }
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
index c230f9781..688b4dc1a 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
@@ -590,8 +590,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
     $this->svelteInitHelper('text', 'Helvetica');
     $page->clickLink('Helvetica');
     $this->assertTrue($assert_session->waitForText('By Hel Vetica'));
-    // cspell:disable-next-line
-    $assert_session->addressEquals('/admin/modules/browse/' . sha1('drupalorg_mockapidrupal/helveticahelvetica'));
+    $assert_session->addressEquals('/admin/modules/browse/drupalorg_mockapi/helvetica');
     $page->clickLink('Back to Browsing');
     $assert_session->addressEquals('admin/modules/browse');
   }
-- 
GitLab