Skip to content
Snippets Groups Projects
InstallerController.php 16.2 KiB
Newer Older
<?php

namespace Drupal\project_browser\Controller;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\DeprecationHelper;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
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;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

/**
 * Defines a controller to install projects via UI.
 */
class InstallerController extends ControllerBase {

  /**
   * No require or install in progress for a given module.
   *
   * @var int
   */
  protected const STATUS_IDLE = 0;

  /**
   * A staging install in progress for a given module.
   *
   * @var int
   */
  protected const STATUS_REQUIRING_PROJECT = 1;

  /**
   * A core install in progress for a given project.
   *
   * @var int
   */
  protected const STATUS_INSTALLING_PROJECT = 2;

  /**
   * The endpoint successfully returned the expected data.
   *
   * @var int
   */
  protected const STAGE_STATUS_OK = 0;

  public function __construct(
    private readonly Installer $installer,
    private readonly EnabledSourceHandler $enabledSourceHandler,
    private readonly TimeInterface $time,
    private readonly LoggerInterface $logger,
    private readonly ActivatorInterface $activator,

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get(Installer::class),
      $container->get(EnabledSourceHandler::class),
      $container->get(TimeInterface::class),
      $container->get('logger.channel.project_browser'),
      $container->get(ActivatorInterface::class),
    );
  }

  /**
   * Checks if UI install is enabled on the site.
   */
  public function access() :AccessResult {
    $ui_install = $this->config('project_browser.admin_settings')->get('allow_ui_install');
    return AccessResult::allowedIf((bool) $ui_install);
  }

  /**
   * Resets progress and destroys the stage.
   */
  private function cancelRequire(): void {
    // Checking the for the presence of a lock in the package manager stage is
    // necessary as this method can be called during create(), which includes
    // both the PreCreate and PostCreate events. If an exception is caught
    // during PreCreate, there's no stage to destroy and an exception would be
    // raised. So, we check for the presence of a stage before calling
    // destroy().
    if (!$this->installer->isAvailable() && $this->installer->lockCameFromProjectBrowserInstaller()) {
      // The risks of forcing a destroy with TRUE are understood, which is why
      // we first check if the lock originated from Project Browser. This
      // function is called if an exception is thrown during an install. This
      // can occur during a phase where the stage might not be claimable, so we
      // force-destroy with the TRUE parameter, knowing that the checks above
      // will prevent destroying an Automatic Updates stage or a stage that is
      // in the process of applying.
      $this->installer->destroy(TRUE);
    }
  }

  /**
   * Returns the status of the project in the temp store.
   *
   * @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.
   */
  public function inProgress(Project $project): JsonResponse {
    $project_state = $this->installState->getStatus($project);
    $return = ['status' => self::STATUS_IDLE];

    if ($project_state !== NULL) {
      $return['status'] = ($project_state === 'requiring' || $project_state === 'applying')
        ? self::STATUS_REQUIRING_PROJECT
        : self::STATUS_INSTALLING_PROJECT;
      $return['phase'] = $project_state;
    }
    return new JsonResponse($return);
  }

  /**
   * Provides a JSON response for a given error.
   *
   * @param \Exception $e
   *   The error that occurred.
   * @param string $phase
   *   The phase the error occurred in.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Provides an error message to be displayed by the Project Browser UI.
   */
  private function errorResponse(\Exception $e, string $phase = ''): JsonResponse {
    $exception_type_short = (new \ReflectionClass($e))->getShortName();
    $exception_message = $e->getMessage();
    $response_body = ['message' => "$exception_type_short: $exception_message"];
    $this->logger->warning('@exception_type: @exception_message. @trace ', [
      '@exception_type' => get_class($e),
      '@exception_message' => $exception_message,
      '@trace' => $e->getTraceAsString(),
    ]);

    if (!empty($phase)) {
      $response_body['phase'] = $phase;
    }
    return new JsonResponse($response_body, 500);
  }

  /**
   * Provides a JSON response for a successful request.
   *
   * @param string $phase
   *   The phase the request was made in.
   * @param string|null $stage_id
   *   The stage ID of the installer within the request.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Provides information about the completed operation.
   */
  private function successResponse(string $phase, ?string $stage_id = NULL): JsonResponse {
    $response_body = [
      'phase' => $phase,
      'status' => self::STAGE_STATUS_OK,
    ];
    if (!empty($stage_id)) {
      $response_body['stage_id'] = $stage_id;
    }
    return new JsonResponse($response_body);
  }

  /**
   * Provides a JSON response for require requests while the stage is locked.
   *
   * @param string $message
   *   The message content of the response.
   * @param string $unlock_url
   *   An unlock url provided in instances where unlocking is safe.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Provides a message regarding the status of the staging lock.
   *
   *   If the stage is not in a phase where it is unsafe to unlock, a CSRF
   *   protected unlock URL is also provided.
   */
  private function lockedResponse(string $message, string $unlock_url = ''): JsonResponse {
    return new JsonResponse([
      'message' => $message,
      'unlock_url' => $unlock_url,
    ], 418);
  }

  /**
   * Unlocks and destroys the stage.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
   *   Redirects to the main project browser page.
   */
  public function unlock(): JsonResponse|RedirectResponse {
    try {
      // It's possible the unlock url was provided before applying began, but
      // accessed after. This final check ensures a destroy is not attempted
      // during apply.
      if ($this->installer->isApplying()) {
        throw new StageException('Another project is being added. Try again in a few minutes.');
      }

      // Adding the TRUE parameter to destroy is dangerous, but we provide it
      // here for a few reasons.
      // - This endpoint is only available if it's confirmed the stage lock was
      //   created by  Drupal\project_browser\ComposerInstaller\Installer.
      // - This endpoint is not available if the stage is applying.
      // - In the event of a flawed install, we want it to be possible for users
      //   to unlock the stage via the GUI, even if they're not the user that
      //   initiated the install.
      // - The unlock link is accompanied by information regarding when the
      //   stage was locked, and warns the user when the time is recent enough
      //   that they risk aborting a legitimate install.
      $this->installer->destroy(TRUE);
    }
    catch (\Exception $e) {
      return $this->errorResponse($e);
    }
    $this->messenger()->addStatus($this->t('Operation complete, you can add a new project again.'));
    return $this->redirect('project_browser.browse');
  }

  /**
   * Gets the given URL with all placeholders replaced.
   *
   * @param \Drupal\Core\Url $url
   *   A URL which generates CSRF token placeholders.
   *
   * @return string
   *   The URL string, with all placeholders replaced.
   */
  private static function getUrlWithReplacedCsrfTokenPlaceholder(Url $url): string {
    $generated_url = $url->toString(TRUE);
    $url_with_csrf_token_placeholder = [
      '#plain_text' => $generated_url->getGeneratedUrl(),
    ];
    $generated_url->applyTo($url_with_csrf_token_placeholder);

    $renderer = \Drupal::service('renderer');
    $output = DeprecationHelper::backwardsCompatibleCall(
      currentVersion: \Drupal::VERSION,
      deprecatedVersion: '10.3',
      currentCallable: fn() => $renderer->renderInIsolation($url_with_csrf_token_placeholder),
      deprecatedCallable: fn() => $renderer->renderPlain($url_with_csrf_token_placeholder),
    );

    return (string) $output;
  }

  /**
   * Begins requiring by creating a stage.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Status message.
   */
  public function begin(): JsonResponse {
    $stage_available = $this->installer->isAvailable();
    if (!$stage_available) {
      $updated_time = $this->installState->getFirstUpdatedTime();
      if (!$this->installer->lockCameFromProjectBrowserInstaller()) {
        return $this->lockedResponse($this->t('The process for adding projects was locked by something else outside of Project Browser. Projects can be added again once the process is unlocked. Try again in a few minutes.'), '');
        $unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
          Url::fromRoute('project_browser.install.unlock')
        );
        $message = t('The process for adding projects is locked, but that lock has expired. Use [+ unlock link] to unlock the process and try to add the project again.');
        return $this->lockedResponse($message, $unlock_url);
      }
      $time_since_updated = $this->time->getRequestTime() - $updated_time;
      $hours = (int) gmdate("H", $time_since_updated);
      $minutes = (int) gmdate("i", $time_since_updated);
      $minutes = $time_since_updated > 60 ? $minutes : 'less than 1';
      if ($this->installer->isApplying()) {
        $message = empty(floor($hours)) ?
          $this->t('The process for adding the project was locked @minutes minutes ago. It should not be unlocked while changes are being applied to the site.', ['@minutes' => $minutes]) :
          $this->t('The process for adding the project was locked @hours hours, @minutes minutes ago. It should not be unlocked
          while changes are being applied to the site.', ['@hours' => $hours, '@minutes' => $minutes]);
        return $this->lockedResponse($message, '');

      }
      elseif ($hours === 0 && ($minutes < 7 || $minutes === 'less than 1')) {
        $message = $this->t('The process for adding the project that was locked @minutes minutes ago might still be in progress. Consider waiting a few more minutes before using [+unlock link].', ['@minutes' => $minutes]);
          $this->t('The process for adding the project was locked @minutes minutes ago. Use [+ unlock link] to unlock the process.', ['@minutes' => $minutes]) :
          $this->t('The process for adding the project was locked @hours hours, @minutes minutes ago. Use [+ unlock link] to unlock the process.',
            ['@hours' => $hours, '@minutes' => $minutes]);
      }

      $unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
        Url::fromRoute('project_browser.install.unlock')
      );
      return $this->lockedResponse($message, $unlock_url);
    }

    try {
      $stage_id = $this->installer->create();
    }
    catch (\Exception $e) {
      $this->cancelRequire();
      return $this->errorResponse($e, 'create');
    }

    return $this->successResponse('create', $stage_id);
  }

  /**
   * Performs require operations on the stage.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request.
   * @param string $stage_id
   *   The stage ID of the installer within the request.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Status message.
   */
  public function require(Request $request, string $stage_id): JsonResponse {
    foreach ($request->toArray() as $project) {
      $project = $this->enabledSourceHandler->getStoredProject($project);
      if ($project->source === 'project_browser_test_mock') {
        $source = $this->enabledSourceHandler->getCurrentSources()[$project->source] ?? NULL;
        if ($source === NULL) {
          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);
        }
      }
      $package_names[] = $project->packageName;
    }
      $this->installer->claim($stage_id)->require($package_names);
      return $this->successResponse('require', $stage_id);
    }
    catch (\Exception $e) {
      $this->cancelRequire();
      return $this->errorResponse($e, 'require');
   * @param string $stage_id
   *   The stage ID of the installer within the request.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Status message.
   */
  public function apply(string $stage_id): JsonResponse {
    foreach (array_keys($this->installState->toArray()) as $project_id) {
      $this->installState->setState($this->enabledSourceHandler->getStoredProject($project_id), 'applying');
    }
    try {
      $this->installer->claim($stage_id)->apply();
    }
    catch (\Exception $e) {
      $this->cancelRequire();
      return $this->errorResponse($e, 'apply');
    }
    return $this->successResponse('apply', $stage_id);
  }

  /**
   * Performs post apply operations on the stage.
   *
   * @param string $stage_id
   *   The stage ID of the installer within the request.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Status message.
   */
  public function postApply(string $stage_id): JsonResponse {
    try {
      $this->installer->claim($stage_id)->postApply();
    }
    catch (\Exception $e) {
      return $this->errorResponse($e, 'post apply');
    }
    return $this->successResponse('post apply', $stage_id);
  }

  /**
   * Performs destroy operations on the stage.
   *
   * @param string $stage_id
   *   The stage ID of the installer within the request.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Status message.
   */
  public function destroy(string $stage_id): JsonResponse {
    try {
      $this->installer->claim($stage_id)->destroy();
    }
    catch (\Exception $e) {
      return $this->errorResponse($e, 'destroy');
    }
    return new JsonResponse([
      'phase' => 'destroy',
      'status' => self::STAGE_STATUS_OK,
      'stage_id' => $stage_id,
    ]);
  }

  /**
   * Installs an already downloaded module.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Status message.
   */
  public function activate(Request $request): JsonResponse {
    foreach ($request->toArray() as $project) {
      $project = $this->enabledSourceHandler->getStoredProject($project);
      try {
        $this->activator->activate($project);
      }
      catch (\Throwable $e) {
        return $this->errorResponse($e, 'project install');
      }
      finally {
    return new JsonResponse(['status' => 0]);