Newer
Older

Ben Mullins
committed
<?php
namespace Drupal\project_browser\Controller;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\TempStore\SharedTempStore;

Ben Mullins
committed
use Drupal\Core\TempStore\SharedTempStoreFactory;
use Drupal\Core\Url;
use Drupal\package_manager\Exception\StageException;
use Drupal\project_browser\ActivatorInterface;

Ben Mullins
committed
use Drupal\project_browser\ComposerInstaller\Installer;
use Drupal\project_browser\EnabledSourceHandler;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;

Ben Mullins
committed
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
* 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;
/**
* The Project Browser tempstore object.

Ben Mullins
committed
*
* @var \Drupal\Core\TempStore\SharedTempStore
*/
protected SharedTempStore $projectBrowserTempStore;
/**
* Constructor for install controller.
*
* @param \Drupal\project_browser\ComposerInstaller\Installer $installer
* The installer service.
* @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_temp_store_factory
* The temporary storage factory.
* @param \Drupal\project_browser\EnabledSourceHandler $enabledSourceHandler
* The enabled project browser source.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The system time.
* @param \Psr\Log\LoggerInterface $logger
* The logger instance.
* @param \Drupal\project_browser\ActivatorInterface $activator
* The project activator service.
public function __construct(
private readonly Installer $installer,
SharedTempStoreFactory $shared_temp_store_factory,
private readonly EnabledSourceHandler $enabledSourceHandler,
private readonly TimeInterface $time,
private readonly LoggerInterface $logger,
private readonly ActivatorInterface $activator,
) {

Ben Mullins
committed
$this->projectBrowserTempStore = $shared_temp_store_factory->get('project_browser');
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('project_browser.installer'),
$container->get('tempstore.shared'),

Ben Mullins
committed
$container->get('project_browser.enabled_source'),
$container->get('datetime.time'),
$container->get('logger.channel.project_browser'),
$container->get(ActivatorInterface::class),

Ben Mullins
committed
);
}
/**
* 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);
}
/**
* Nulls the installing and core installing states.
*/
private function resetProgress(): void {
$this->projectBrowserTempStore->delete('requiring');
$this->projectBrowserTempStore->delete('installing');
}
/**
* Resets progress and destroys the stage.
*/
private function cancelRequire(): void {
$this->resetProgress();
// 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);

Ben Mullins
committed
}
}
/**
* Returns the status of the project in the temp store.
*
* @param string $source
* The source plugin ID.

Ben Mullins
committed
* @param string $project_id
* The ID of the project, as known to the source plugin.

Ben Mullins
committed
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Information about the project's require/install status.
*
* If the project is being required, the response will include which require
* phase is currently occurring.
*
* When a project is required via the UI, the UI fetches this endpoint
* regularly so it can monitor the progress of the process and report which
* stage is taking place.
*/
public function inProgress(string $source, string $project_id): JsonResponse {

Ben Mullins
committed
$requiring = $this->projectBrowserTempStore->get('requiring');
$core_installing = $this->projectBrowserTempStore->get('installing');
$return = ['status' => self::STATUS_IDLE];
// Prepend the source plugin ID, to create a fully qualified project ID.
$project_id = $source . '/' . $project_id;

Ben Mullins
committed
if (isset($requiring['project_id']) && $requiring['project_id'] === $project_id) {
$return['status'] = self::STATUS_REQUIRING_PROJECT;
$return['phase'] = $requiring['phase'];

Ben Mullins
committed
}
if ($core_installing === $project_id) {
$return['status'] = self::STATUS_INSTALLING_PROJECT;

Ben Mullins
committed
}

Ben Mullins
committed
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
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);
}
/**
* Updates the 'requiring' state in the temp store.
*
* @param string $project_id
* The fully qualified ID of the project being required.

Ben Mullins
committed
* @param string $phase
* The require phase in progress.
* @param string $stage_id
* The stage id.
*/
private function setRequiringState(?string $project_id, string $phase, ?string $stage_id): void {
$data = $this->projectBrowserTempStore->get('requiring') ?? [];
if ($project_id) {
$data['project_id'] = $project_id;
}
if ($stage_id) {
$data['stage_id'] = $stage_id;
}
$data['phase'] = $phase;
$this->projectBrowserTempStore->set('requiring', $data);

Ben Mullins
committed
}
/**
* 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 {

Ben Mullins
committed
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()) {

James Shields
committed
throw new StageException($this->installer, 'A stage can not be unlocked while applying');

Ben Mullins
committed
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
}
// 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->projectBrowserTempStore->delete('requiring');
$this->messenger()->addStatus($this->t('Install staging area unlocked.'));
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');
// @todo Remove this if statement, and everything below it, when Drupal
// <10.3 support is dropped.
if (method_exists($renderer, 'renderInIsolation')) {
return (string) $renderer->renderInIsolation($url_with_csrf_token_placeholder);
}
// @phpstan-ignore-next-line
return (string) $renderer->renderPlain($url_with_csrf_token_placeholder);

Ben Mullins
committed
}
/**
* Begins requiring by creating a stage.
*
* @param string $source
* The source plugin ID.

Ben Mullins
committed
* @param string $project_id
* The ID of the project, as known to the source plugin.

Ben Mullins
committed
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function begin(string $source, string $project_id): JsonResponse {
$source_id = $source;

Ben Mullins
committed
// @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 $project_id from any available source"], 500);
}
if (!$source->isProjectSafe($project_id)) {
return new JsonResponse(['message' => "$project_id is not safe to add because its security coverage has been revoked"], 500);
}

Ben Mullins
committed
$stage_available = $this->installer->isAvailable();
if (!$stage_available) {
$requiring_metadata = $this->projectBrowserTempStore->getMetadata('requiring');
if (!$this->installer->lockCameFromProjectBrowserInstaller()) {
return $this->lockedResponse($this->t('The installation stage is locked by a process outside of Project Browser'), '');
}
if (empty($requiring_metadata)) {
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock')
);
$message = t('An install staging area claimed by Project Browser exists but has expired. You may unlock the stage and try the install again.');
return $this->lockedResponse($message, $unlock_url);
}
$time_since_updated = $this->time->getRequestTime() - $requiring_metadata->getUpdated();
$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 install staging area was locked @minutes minutes ago. It should not be unlocked as the changes from staging are being applied to the site.', [
'@minutes' => $minutes,
]) :
$this->t('The install staging area was locked @hours hours, @minutes minutes ago. It should not be unlocked as the changes from staging are being applied to the site.', [
'@hours' => $hours,
'@minutes' => $minutes,
]);

Ben Mullins
committed
return $this->lockedResponse($message, '');
}
elseif ($hours === 0 && ($minutes < 7 || $minutes === 'less than 1')) {
$message = $this->t('The install staging area was locked @minutes minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.', ['@minutes' => $minutes]);
}
else {
$message = empty($hours) ?
$this->t('The install staging area was locked @minutes minutes ago.', ['@minutes' => $minutes]) :
$this->t('The install staging area was locked @hours hours, @minutes minutes ago.', [
'@hours' => $hours,
'@minutes' => $minutes,
]);

Ben Mullins
committed
}
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock')
);
return $this->lockedResponse($message, $unlock_url);
}
try {
$stage_id = $this->installer->create();
$this->setRequiringState($source_id . '/' . $project_id, 'creating install stage', $stage_id);

Ben Mullins
committed
}
catch (\Exception $e) {
$this->cancelRequire();
return $this->errorResponse($e, 'create');
}
return $this->successResponse('create', $stage_id);
}
/**
* Performs require operations on the stage.
*
* @param string $source
* The source plugin ID.

Ben Mullins
committed
* @param string $project_id
* The ID of the project, as known to the source plugin.

Ben Mullins
committed
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function require(string $source, string $project_id): JsonResponse {

Ben Mullins
committed
$requiring = $this->projectBrowserTempStore->get('requiring');
if (empty($requiring['project_id']) || $requiring['project_id'] !== $source . '/' . $project_id) {

Ben Mullins
committed
return new JsonResponse([
'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);
$projects = $this->enabledSourceHandler->getCurrentSources()[$source]?->getProjects()->list ?? [];
foreach ($projects as $project) {
if ($project->id === $project_id) {
try {
$this->installer->claim($requiring['stage_id'])->require([
$project->packageName,
]);
}
catch (\Exception $e) {
$this->cancelRequire();
return $this->errorResponse($e, 'require');
}
}

Ben Mullins
committed
}
return $this->successResponse('require', $requiring['stage_id']);

Ben Mullins
committed
}
/**
* Performs apply operations on the stage.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function apply(): JsonResponse {
$stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id'];
$this->setRequiringState(NULL, 'applying', NULL);

Ben Mullins
committed
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.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function postApply(): JsonResponse {
$stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id'];
$this->setRequiringState(NULL, 'post apply', NULL);

Ben Mullins
committed
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.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function destroy(): JsonResponse {
$stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id'];
$this->setRequiringState(NULL, 'completing', NULL);

Ben Mullins
committed
try {
$this->installer->claim($stage_id)->destroy();
}
catch (\Exception $e) {
return $this->errorResponse($e, 'destroy');
}
$this->projectBrowserTempStore->delete('requiring');
return new JsonResponse([
'phase' => 'destroy',
'status' => self::STAGE_STATUS_OK,
'stage_id' => $stage_id,
]);
}
/**
* Installs an already downloaded module.
*
* @param string $source
* The source plugin ID.

Ben Mullins
committed
* @param string $project_id
* The ID of the project, as known to the source plugin.

Ben Mullins
committed
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function activate(string $source, string $project_id): JsonResponse {

Ben Mullins
committed
$this->projectBrowserTempStore->set('installing', $project_id);
$projects = $this->enabledSourceHandler->getCurrentSources()[$source]?->getProjects()->list ?? [];
foreach ($projects as $project) {
if ($project->id === $project_id) {
try {
$this->activator->activate($project);
}
catch (\Throwable $e) {
return $this->errorResponse($e, 'project install');
}
finally {
$this->resetProgress();
}
}

Ben Mullins
committed
}
return new JsonResponse(['status' => 0]);